新增: 文件浏览器增强(窗口分离/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
This commit is contained in:
2026-06-29 19:25:30 +08:00
parent 63936e3016
commit dfed588b54
20 changed files with 1812 additions and 159 deletions

View File

@@ -49,7 +49,7 @@ import AppLayout from './components/layout/AppLayout.vue'
import { aiApi } from '@/api'
const route = useRoute()
const isDetached = computed(() => route.path === '/ai-detached')
const isDetached = computed(() => route.path === '/ai-detached' || route.path === '/file-explorer-detached')
const aiStore = useAiStore()
const appSettings = useAppSettingsStore()

View File

@@ -63,6 +63,8 @@ export interface GitChangedFile {
export interface GitRecentCommit {
hash: string
subject: string
/** Unix 时间戳(秒) */
timestamp: number
}
/** getModuleGitStatus 返回结构。 */
@@ -74,6 +76,17 @@ export interface GitStatusResult {
}
export const moduleApi = {
/** 新增工程(返回完整记录)。 */
addProjectModule(input: {
projectId: string
name: string
path: string
gitUrl?: string | null
stack?: string | null
}): Promise<ProjectModuleRecord> {
return invoke('add_project_module', { input })
},
/** 列出项目全部工程(按 sort_order ASC)。 */
listProjectModules(projectId: string): Promise<ProjectModuleRecord[]> {
return invoke('list_project_modules', { projectId })
@@ -104,4 +117,30 @@ export const moduleApi = {
readModuleFile(moduleId: string, filePath: string): Promise<ReadFileResult> {
return invoke('read_module_file', { 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 })
},
/** 查询工程内文件的 git diff(轻量,不读文件内容)。返回 { path, diff }。 */
getModuleFileDiff(moduleId: string, filePath: string): Promise<{ path: string; diff: string }> {
return invoke('get_module_file_diff', { moduleId, filePath })
},
/** 分页查询工程 Git 提交历史。返回 { commits, has_more }。 */
getModuleCommits(moduleId: string, skip?: number, limit?: number): Promise<{
commits: { hash: string; subject: string; timestamp: number }[]
has_more: boolean
}> {
return invoke('get_module_commits', { moduleId, skip: skip ?? 0, limit: limit ?? 50 })
},
/** 查询某次提交的变更文件列表及 diff。返回 { files: [{ status, path }], diff: string }。 */
getCommitDetail(moduleId: string, commitHash: string): Promise<{
files: { status: string; path: string }[]
diff: string
}> {
return invoke('get_commit_detail', { moduleId, commitHash })
},
}

View File

@@ -31,38 +31,101 @@
</div>
<div class="toolbar-right">
<button class="btn btn-ghost btn-sm" type="button" title="添加工程" @click="showAddModule = true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<button class="btn btn-ghost btn-sm" type="button" :disabled="refreshing" @click="refresh">
<span v-if="refreshing" class="spinner"></span>
{{ refreshing ? $t('fileExplorer.refreshing') : $t('fileExplorer.refresh') }}
</button>
<button class="btn btn-ghost btn-sm" type="button" title="弹出到独立窗口" @click="onDetach">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 3h6v6"/>
<path d="M10 14L21 3"/>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
</svg>
</button>
</div>
</div>
<!-- 主体:文件树 + 预览 -->
<!-- 主体:文件树/变更列表 + 预览 -->
<div class="explorer-body">
<!-- :文件树 -->
<div class="explorer-tree">
<FileTree
:module-id="currentModuleId"
sub-path=""
:indent="12"
:expanded-paths="expandedPaths"
:loaded-children="loadedChildren"
:selected-path="selectedFilePath"
@toggle-dir="onToggleDir"
@file-select="onFileSelect"
@load-children="onLoadChildren"
/>
<!-- :视图切换按钮(文件树 / 变更) -->
<div class="explorer-sidebar">
<div class="explorer-view-tabs">
<button class="view-tab" :class="{ active: viewMode === 'tree' }" @click="viewMode = 'tree'">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
</button>
<button class="view-tab" :class="{ active: viewMode === 'changes' }" @click="switchToChanges">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
<span v-if="changedCount > 0" class="changed-badge">{{ changedCount }}</span>
</button>
</div>
<!-- 文件树视图 -->
<div v-show="viewMode === 'tree'" class="explorer-tree">
<div v-if="!currentModuleId" class="tree-loading">
<span class="spinner"></span>
</div>
<FileTree
v-else
:module-id="currentModuleId"
sub-path=""
:indent="12"
:expanded-paths="expandedPaths"
:loaded-children="loadedChildren"
:selected-path="selectedFilePath"
@toggle-dir="onToggleDir"
@file-select="onFileSelect"
@load-children="onLoadChildren"
/>
</div>
<!-- Git 变更视图 -->
<div v-show="viewMode === 'changes'" class="explorer-changes">
<GitChanges
v-if="currentModuleId"
:module-id="currentModuleId"
@select-file="onChangeFileSelect"
/>
</div>
</div>
<!-- :文件预览 -->
<!-- :文件预览(moduleId 为空时不渲染,避免空 moduleId IPC 调用报错) -->
<div class="explorer-preview">
<FilePreview
v-if="currentModuleId"
:module-id="currentModuleId"
:file-path="selectedFilePath"
:module-root-path="currentModule?.path ?? ''"
:git-status="selectedFileGitStatus"
/>
<div v-else class="preview-placeholder-inline">{{ $t('fileExplorer.selectFileHint') }}</div>
</div>
</div>
</div>
<!-- 新增工程弹窗 -->
<div v-if="showAddModule" class="modal-overlay" @click.self="showAddModule = false">
<div class="modal-box modal-box--sm">
<h3 class="modal-title">{{ $t('fileExplorer.addModule') }}</h3>
<div class="modal-field">
<label>{{ $t('fileExplorer.moduleName') }}</label>
<input v-model="newModuleName" :placeholder="$t('fileExplorer.moduleNamePlaceholder')" />
</div>
<div class="modal-field">
<label>{{ $t('fileExplorer.modulePath') }}</label>
<input v-model="newModulePath" :placeholder="$t('fileExplorer.modulePathPlaceholder')" />
</div>
<div class="modal-field">
<label>{{ $t('fileExplorer.moduleGitUrl') }} <span class="opt-label">{{ $t('common.optional') }}</span></label>
<input v-model="newModuleGitUrl" :placeholder="$t('fileExplorer.moduleGitUrlPlaceholder')" />
</div>
<div class="modal-actions">
<button class="btn btn-ghost" @click="showAddModule = false">{{ $t('common.cancel') }}</button>
<button class="btn btn-primary" :disabled="addingModule || !newModuleName.trim() || !newModulePath.trim()" @click="onAddModule">
{{ addingModule ? $t('fileExplorer.adding') : $t('fileExplorer.addModule') }}
</button>
</div>
</div>
</div>
@@ -81,18 +144,30 @@
* 单工程自动选中;多工程默认选首个 + 提供下拉切换。
*/
import { ref, computed, watch, reactive } from 'vue'
import { moduleApi, type ProjectModuleRecord, type FileTreeEntry } from '@/api/module'
import { useI18n } from 'vue-i18n'
import { moduleApi, type ProjectModuleRecord, type FileTreeEntry, type GitStatusResult } from '@/api/module'
import FileTree from './FileTree.vue'
import FilePreview from './FilePreview.vue'
import GitChanges from './GitChanges.vue'
import { detachFileExplorer } from '@/composables/project/useFileExplorerWindow'
const props = defineProps<{
projectId: string
}>()
const { t } = useI18n()
const modules = ref<ProjectModuleRecord[]>([])
const currentModuleId = ref<string>('')
const refreshing = ref(false)
// 新增工程弹窗
const showAddModule = ref(false)
const addingModule = ref(false)
const newModuleName = ref('')
const newModulePath = ref('')
const newModuleGitUrl = ref('')
// 树状态(顶层持有,递归子树共享 props)。
const expandedPaths = reactive(new Set<string>())
const loadedChildren = reactive(new Map<string, FileTreeEntry[]>())
@@ -133,7 +208,7 @@ async function loadModules() {
}
}
/** 重置树状态(切工程/刷新时调用)。 */
/** 重置树状态(切工程时调用,清展开/缓存/选中文件;刷新时保留选中文件由 refresh 单独处理)。 */
function resetTreeState() {
expandedPaths.clear()
loadedChildren.clear()
@@ -178,27 +253,25 @@ function onLoadChildren(_path: string) {
// 占位:FileTree 内部已自拉并缓存到 loadedChildren;此处保留事件入口便于未来扩展(如统一节流)。
}
/** 刷新根目录(清缓存 + FileTree 因 loadedChildren 失效自重拉)。 */
/** 刷新根目录(清展开/缓存,保留已打开的文件预览不变)。 */
async function refresh() {
refreshing.value = true
resetTreeState()
// 仅清树状态(展开目录 + 缓存条目),不清 selectedFilePath/selectedFileGitStatus,
// 让用户刷新的同时仍能看到正在查看的文件内容。
expandedPaths.clear()
loadedChildren.clear()
// 等一个 tick 让 FileTree watch 触发重拉;实际拉取在子组件,这里只做状态清空。
await new Promise((r) => setTimeout(r, 50))
refreshing.value = false
}
/** 面包屑 → 根。 */
/** 面包屑 → 根(仅清展开状态,保留当前打开的预览文件)。 */
function goRoot() {
selectedFilePath.value = null
selectedFileGitStatus.value = undefined
expandedPaths.clear()
}
/** 面包屑 → 某段(展开父链 + 选中该目录)。 */
/** 面包屑 → 某段(展开父链使该目录可见,不改变当前预览文件)。 */
function goBreadcrumb(path: string) {
// 选中该路径作为"焦点"(文件树保持展开,仅清当前文件选中,面包屑切到此目录)。
selectedFilePath.value = path
selectedFileGitStatus.value = undefined
// 确保父链展开(否则面包屑跳转后看不到该目录)。
const parts = path.split('/').filter(Boolean)
let acc = ''
@@ -208,7 +281,58 @@ function goBreadcrumb(path: string) {
}
}
/** 弹出文件浏览器到独立窗口。 */
function onDetach() {
detachFileExplorer(props.projectId, currentModule.value?.name || t('fileExplorer.detachTitle'))
}
// ── 视图模式切换:文件树 / Git 变更 ──
const viewMode = ref<'tree' | 'changes'>('tree')
const gitStatusData = ref<GitStatusResult | null>(null)
const changedCount = computed(() => gitStatusData.value?.changed_files.length ?? 0)
async function switchToChanges() {
viewMode.value = 'changes'
if (!currentModuleId.value || gitStatusData.value) return
try {
gitStatusData.value = await moduleApi.getModuleGitStatus(currentModuleId.value)
} catch {
gitStatusData.value = null
}
}
/** Git 变更视图中选择文件 → 在右侧预览显示 diff。 */
function onChangeFileSelect(path: string) {
selectedFilePath.value = path
selectedFileGitStatus.value = gitStatusData.value?.changed_files.find(f => f.path === path)?.status
}
watch(() => props.projectId, loadModules, { immediate: true })
/** 提交新增工程。 */
async function onAddModule() {
if (addingModule.value) return
addingModule.value = true
try {
await moduleApi.addProjectModule({
projectId: props.projectId,
name: newModuleName.value.trim(),
path: newModulePath.value.trim(),
gitUrl: newModuleGitUrl.value.trim() || null,
})
showAddModule.value = false
newModuleName.value = ''
newModulePath.value = ''
newModuleGitUrl.value = ''
// 刷新工程列表
await loadModules()
} catch (e) {
console.error('[FileExplorer] 添加工程失败:', e)
} finally {
addingModule.value = false
}
}
</script>
<style scoped>
@@ -216,7 +340,6 @@ watch(() => props.projectId, loadModules, { immediate: true })
display: flex;
flex-direction: column;
height: 100%;
min-height: 480px;
background: var(--df-bg);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg, 8px);
@@ -330,26 +453,164 @@ watch(() => props.projectId, loadModules, { immediate: true })
font-size: 11px;
}
/* 主体两栏 */
/* 主体两栏 — 自适应比例 30% / 70% */
.explorer-body {
flex: 1;
display: flex;
overflow: hidden;
}
.explorer-tree {
width: 320px;
min-width: 240px;
max-width: 480px;
overflow: auto;
padding: 8px 4px 8px 0;
/* 左侧边栏(视图切换 + 内容) */
.explorer-sidebar {
width: 30%;
min-width: 200px;
max-width: 420px;
display: flex;
flex-direction: column;
border-right: 0.5px solid var(--df-border);
flex-shrink: 0;
overflow: hidden;
}
/* 视图切换 Tab */
.explorer-view-tabs {
display: flex;
border-bottom: 0.5px solid var(--df-border);
flex-shrink: 0;
}
.view-tab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 0;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--df-text-dim);
cursor: pointer;
font-size: 11px;
transition: all 0.15s;
position: relative;
}
.view-tab:hover {
color: var(--df-text);
background: rgba(255,255,255,0.03);
}
.view-tab.active {
color: var(--df-text);
border-bottom-color: var(--df-accent);
}
.changed-badge {
position: absolute;
top: 3px;
right: 8px;
min-width: 14px;
height: 14px;
padding: 0 4px;
border-radius: 7px;
background: var(--df-accent);
color: #fff;
font-size: 9px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.explorer-tree {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 8px 4px 8px 0;
scrollbar-width: thin;
}
.explorer-changes {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
scrollbar-width: thin;
}
.explorer-changes .gc-body {
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
}
.tree-loading {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}
.tree-loading .spinner {
width: 20px;
height: 20px;
border: 2px solid var(--df-border, #444);
border-top-color: var(--df-accent, #3a8);
border-radius: 50%;
animation: df-spin 0.8s linear infinite;
}
.explorer-preview {
flex: 1;
min-width: 0;
overflow: hidden;
scrollbar-width: thin;
}
.preview-placeholder-inline {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--df-text-dim);
font-size: 13px;
min-height: 200px;
}
/* 全局滚动条样式(薄) */
.explorer-sidebar ::-webkit-scrollbar {
width: 4px;
height: 4px;
}
.explorer-sidebar ::-webkit-scrollbar-thumb {
background: var(--df-border);
border-radius: 2px;
}
.explorer-sidebar ::-webkit-scrollbar-track {
background: transparent;
}
/* 窄窗口自适应 */
@media (max-width: 900px) {
.explorer-sidebar {
width: 240px;
min-width: 160px;
max-width: 280px;
}
}
@media (max-width: 600px) {
.explorer-toolbar {
flex-wrap: wrap;
gap: 6px;
}
.explorer-sidebar {
width: 180px;
min-width: 120px;
}
}
/* spinner(刷新按钮内) */

View File

@@ -1,11 +1,10 @@
<template>
<!--
文件内容预览(Batch 10)
- 文本/代码:<pre><code> 展示(暂不做语法高亮,后续可接 highlight.js / monaco)
- 图片:<img> 展示( tauri:// 或 convertFileSrc)
- 二进制:提示"二进制文件不支持预览"
- 顶部:文件路径 + 大小 + Git 状态
- 加载态:spinner
文件内容预览
- 文本/代码: highlight.js 语法高亮(按文件后缀自动选语言),git 变更可切 diff 视图
- 图片: <img> 展示( convertFileSrc)
- 二进制: 提示"二进制文件不支持预览"
- 顶部: 文件路径 + 大小 + Git 状态 + Diff 切换按钮
-->
<div class="file-preview">
<!-- 顶部元信息 -->
@@ -19,6 +18,16 @@
<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>
@@ -49,39 +58,49 @@
<img :src="imageUrl ?? ''" :alt="filePath ?? ''" />
</div>
<!-- 文本/代码 -->
<pre v-else class="preview-code"><code>{{ content }}</code></pre>
<!-- 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">
/**
* 文件预览组件。
*
* 调用 readModuleFile IPC 拉内容,按返回的 is_binary / 路径后缀决定渲染分支:
* - is_binary=true → 二进制提示分支
* - 后缀命中图片白名单 → convertFileSrc 走 tauri 资源协议直显(避免大图 base64 撑爆内存)
* - 其余 → 文本/代码分支(<pre><code> 保留空白,等宽字体)
*
* convertFileSrc 用法:把本机绝对文件路径转成 tauri://localhost/... 资源 URL,
* 前端 <img> 可直接加载(Tauri 配置需允许该路径;默认 dev/release 均放行应用数据目录,
* 工程目录通常不在白名单 → 见下方注意事项,降级用 readModuleFile 拿字节 + dataURL)。
*/
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
/** 相对工程根的文件路径;空/null = 未选文件(显示占位)。 */
filePath: string | null
/** 工程根绝对路径(用于图片 convertFileSrc 拼绝对路径)。 */
moduleRootPath: string
/** 该文件的 git 状态(来自文件树条目,可选)。 */
gitStatus?: string
}>()
const { } = useMarkdown()
const content = ref('')
const htmlContent = ref('')
const loading = ref(false)
const error = ref<string | null>(null)
const isBinary = ref(false)
@@ -89,25 +108,123 @@ 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))
})
/** 拉文件内容。图片走 blob URL(避免 convertFileSrc 受路径白名单限制)。 */
/** 拉文件内容 + 高亮渲染。 */
async function loadFile() {
if (!props.filePath) {
content.value = ''
htmlContent.value = ''
imageUrl.value = null
return
}
loading.value = true
error.value = null
// 释放上一张图的 blob URL(防内存泄漏)。
// Markdown 文件触发预热(确保 marked 就绪,useRendered computed 据此渲染)
if (props.filePath.toLowerCase().endsWith('.md')) {
ensureMdLoaded()
}
if (imageUrl.value) {
URL.revokeObjectURL(imageUrl.value)
imageUrl.value = null
@@ -119,17 +236,14 @@ async function loadFile() {
isBinary.value = res.is_binary
if (res.is_binary) {
content.value = ''
htmlContent.value = ''
return
}
if (isImage.value) {
// 图片:把文本内容(其实后端对图片也走 lossy 转 string,这里不可靠)→ 改走
// convertFileSrc 拿到本机资源 URL。失败时 fallback 到文本分支(至少不崩)。
// 注:Tauri 默认放行 app data dir;工程目录若不在白名单会显示 broken image,
// 这是已知降级,后续可通过 tauri.conf.json 的 assetProtocol 放行工程目录。
content.value = ''
htmlContent.value = ''
try {
const { convertFileSrc } = await import('@tauri-apps/api/core')
// 拼绝对路径:moduleRootPath + filePath(POSIX → 平台分隔符)。
const abs = joinPath(props.moduleRootPath, props.filePath)
imageUrl.value = convertFileSrc(abs)
} catch {
@@ -137,6 +251,19 @@ async function loadFile() {
}
} 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)
@@ -145,7 +272,10 @@ async function loadFile() {
}
}
/** 拼接工程根 + 相对路径为绝对路径(平台分隔符)。 */
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)
@@ -153,7 +283,6 @@ function joinPath(root: string, rel: string): string {
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`
@@ -178,7 +307,11 @@ function gitStatusLabel(status: string): string {
return x.charAt(0) || '?'
}
watch(() => [props.moduleId, props.filePath], loadFile, { immediate: true })
watch(() => [props.moduleId, props.filePath], () => {
showDiff.value = false
diffContent.value = ''
loadFile()
}, { immediate: true })
onUnmounted(() => {
if (imageUrl.value) URL.revokeObjectURL(imageUrl.value)
@@ -222,15 +355,8 @@ onUnmounted(() => {
max-width: 460px;
}
.preview-size {
color: var(--df-text-dim);
flex-shrink: 0;
}
.preview-truncated {
color: #f0a020;
flex-shrink: 0;
}
.preview-size { color: var(--df-text-dim); flex-shrink: 0; }
.preview-truncated { color: #f0a020; flex-shrink: 0; }
.git-badge {
flex-shrink: 0;
@@ -242,31 +368,19 @@ onUnmounted(() => {
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;
}
.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: auto;
overflow: hidden;
padding: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.preview-placeholder,
@@ -284,14 +398,8 @@ onUnmounted(() => {
min-height: 200px;
}
.preview-error {
color: #e05050;
}
.binary-icon {
font-size: 32px;
opacity: 0.5;
}
.preview-error { color: #e05050; }
.binary-icon { font-size: 32px; opacity: 0.5; }
.preview-image {
display: flex;
@@ -314,14 +422,59 @@ onUnmounted(() => {
font-family: var(--df-font-mono, 'Cascadia Code', Consolas, 'Courier New', monospace);
font-size: 12.5px;
line-height: 1.55;
color: var(--df-text);
white-space: pre;
overflow: auto;
tab-size: 2;
flex: 1;
min-width: 0;
min-height: 0;
}
.preview-code code {
font-family: inherit;
.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 {
@@ -334,9 +487,94 @@ onUnmounted(() => {
display: inline-block;
}
@keyframes df-spin {
to {
transform: rotate(360deg);
}
/* 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>

View File

@@ -50,6 +50,7 @@
>
<span class="expand-icon placeholder"></span>
<span class="file-icon">📄</span>
<span class="file-icon file-icon-ext" v-html="fileIcon(entry.name)"></span>
<span class="file-name">{{ entry.name }}</span>
<!-- Git 状态标记(仅文件):M / A 绿 / ?? -->
<span
@@ -127,6 +128,8 @@ const error = ref<string | null>(null)
/** 拉取当前 subPath 的条目列表。 */
async function loadEntries() {
// moduleId 为空时不发起请求(工程列表还在加载中)
if (!props.moduleId) return
loading.value = true
error.value = null
try {
@@ -144,6 +147,8 @@ async function loadEntries() {
/** 点击文件夹:切换展开态。 */
async function toggleDir(entry: FileTreeEntry) {
if (!entry.is_dir) return
// moduleId 为空时不发起请求(工程列表还在加载中)
if (!props.moduleId) return
if (props.expandedPaths.has(entry.path)) {
// 收起:仅移除展开标记(loadedChildren 缓存保留,下次展开秒开)。
emit('toggle-dir', entry.path, [])
@@ -188,6 +193,40 @@ function gitStatusLabel(status: string): string {
return x.charAt(0) || '?'
}
/** 文件后缀 → SVG 图标映射(纯文本,安全注入 v-html)。 */
const FILE_ICONS: Record<string, string> = {
// 代码
rs: '<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" fill="#DE5842"/><path d="M8 8h8M8 12h8M8 16h5" stroke="#fff" stroke-width="1.5" stroke-linecap="round"/></svg>',
ts: '<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" fill="#3178C6"/><text x="12" y="16" text-anchor="middle" fill="#fff" font-size="11" font-weight="bold">TS</text></svg>',
tsx: '<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" fill="#3178C6"/><text x="12" y="16" text-anchor="middle" fill="#fff" font-size="10" font-weight="bold">TSX</text></svg>',
js: '<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" fill="#F7DF1E"/><text x="12" y="16" text-anchor="middle" fill="#000" font-size="11" font-weight="bold">JS</text></svg>',
jsx: '<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" fill="#F7DF1E"/><text x="12" y="16" text-anchor="middle" fill="#000" font-size="10" font-weight="bold">JSX</text></svg>',
vue: '<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" fill="#42B883"/><path d="M7 7l5 10 5-10h-3l-2 4-2-4H7z" fill="#fff"/></svg>',
// 样式
css: '<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" fill="#2965F1"/><path d="M7 7l1 10 4 1.5 4-1.5 1-10H7z" fill="#fff" opacity="0.9"/></svg>',
scss: '<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" fill="#C6538C"/><text x="12" y="16" text-anchor="middle" fill="#fff" font-size="9" font-weight="bold">SCSS</text></svg>',
html: '<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" fill="#E34F26"/><path d="M8 7l-.5 5.5h8.5l-.5 4-3.5 1-3.5-1" stroke="#fff" stroke-width="1.2" fill="none"/><path d="M12 12h3l-.5-3H12" stroke="#fff" stroke-width="1.2" fill="none"/></svg>',
// 配置
json: '<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" fill="#5C5C5C"/><text x="12" y="16" text-anchor="middle" fill="#fff" font-size="8" font-weight="bold">{ }</text></svg>',
toml: '<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" fill="#8B5CF6"/><text x="12" y="16" text-anchor="middle" fill="#fff" font-size="9" font-weight="bold">TOML</text></svg>',
yaml: '<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" fill="#6BB5A0"/><text x="12" y="16" text-anchor="middle" fill="#fff" font-size="9" font-weight="bold">YM</text></svg>',
yml: '<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" fill="#6BB5A0"/><text x="12" y="16" text-anchor="middle" fill="#fff" font-size="9" font-weight="bold">YM</text></svg>',
// 文档
md: '<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" fill="#083FA1"/><path d="M7 15V9l2.5 3L12 9v6" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>',
// Shell
sh: '<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" fill="#4EAA25"/><path d="M8 9l3 3-3 3M13 15h3" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>',
bash: '<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" fill="#4EAA25"/><path d="M8 9l3 3-3 3M13 15h3" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>',
// 构建
dockerfile: '<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" fill="#2496ED"/><path d="M7 12h1M9 12h1M11 12h1M13 12h1M15 12h1M17 10h-2v2h2v-2zM10 9H8v2h2V9z" stroke="#fff" stroke-width="1" fill="none"/></svg>',
sql: '<svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" fill="#E38C00"/><text x="12" y="16" text-anchor="middle" fill="#fff" font-size="8" font-weight="bold">SQL</text></svg>',
}
/** 文件后缀 → 图标 HTML(v-html 安全注入)。 */
function fileIcon(name: string): string {
const ext = name.split('.').pop()?.toLowerCase() ?? ''
return FILE_ICONS[ext] || ''
}
onMounted(loadEntries)
// moduleId 变化(切换工程)时重拉。
@@ -198,6 +237,8 @@ watch(
() => props.loadedChildren.has(props.subPath || ''),
(has) => {
if (!has && entries.value.length > 0) {
// moduleId 为空时不发起请求
if (!props.moduleId) return
loadEntries()
}
},
@@ -260,6 +301,22 @@ watch(
.file-icon {
font-size: 14px;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
}
.file-icon-ext {
margin-left: -18px;
}
.file-icon-ext svg {
width: 18px;
height: 18px;
display: block;
}
.file-name {

View File

@@ -0,0 +1,490 @@
<template>
<div class="git-changes">
<!-- 分支信息 -->
<div v-if="branchName" class="git-branch-bar">
<span class="branch-icon"></span>
<span class="branch-name">{{ branchName }}</span>
<span v-if="!showHistory" class="branch-count">{{ gitStatus?.changed_files.length ?? 0 }} {{ $t('gitChanges.changedFiles') }}</span>
</div>
<div v-if="loading && !gitStatus" class="gc-loading"><span class="spinner"></span></div>
<!-- Git 仓库 -->
<div v-else-if="gitStatus && !gitStatus.is_git_repo" class="gc-empty">
{{ $t('gitChanges.notGitRepo') }}
</div>
<div v-else-if="gitStatus" class="gc-body">
<!-- Tab: 变更 / 历史 -->
<nav class="gc-tabs">
<button class="gc-tab" :class="{ active: !showHistory }" @click="showHistory = false">
{{ $t('gitChanges.changes') }} ({{ gitStatus.changed_files.length }})
</button>
<button class="gc-tab" :class="{ active: showHistory }" @click="switchToHistory">
{{ $t('gitChanges.history') }} ({{ totalCommits }})
</button>
</nav>
<!-- ====== 变更列表 ====== -->
<div v-if="!showHistory" class="gc-changes">
<template v-if="gitStatus.changed_files.length === 0">
<div class="gc-empty">{{ $t('gitChanges.noChanges') }}</div>
</template>
<!-- 按目录树分组的变更文件 -->
<div v-for="group in groupedChanges" :key="group.label" class="gc-group">
<div class="gc-group-header">{{ group.label }} ({{ group.files.length }})</div>
<div v-for="f in group.files" :key="f.path"
class="gc-file-row"
:class="{ selected: selectedFile === f.path }"
:style="{ paddingLeft: filePathDepth(f.path) * 16 + 14 + 'px' }"
@click="selectFile(f.path)"
>
<span class="gc-file-status" :class="'gc-st-' + group.key">{{ statusLabel(f.status) }}</span>
<span class="gc-file-name">{{ filePathLeaf(f.path) }}</span>
</div>
</div>
</div>
<!-- ====== 提交历史 ====== -->
<div v-else class="gc-history-wrapper">
<!-- 已选提交的详情 -->
<div v-if="selectedCommit" class="gc-commit-detail">
<div class="gc-commit-detail-header">
<span class="gc-commit-detail-hash">{{ selectedCommit.hash }}</span>
<span class="gc-commit-detail-msg">{{ selectedCommit.subject }}</span>
<span class="gc-commit-detail-time">{{ formatTime(selectedCommit.timestamp) }}</span>
<button class="gc-detail-close" @click="selectedCommit = null; commitDetail = null"></button>
</div>
<div v-if="commitDetailLoading" class="gc-loading"><span class="spinner"></span></div>
<template v-else-if="commitDetail">
<!-- 变更文件列表 -->
<div class="gc-commit-files">
<div
v-for="f in commitDetail.files"
:key="f.path"
class="gc-commit-file-row"
:class="{ active: commitSelectedFile === f.path }"
@click="showCommitFileDiff(f.path)"
>
<span class="gc-file-status" :class="'gc-st-' + statusKey(f.status)">{{ statusLabel(f.status) }}</span>
<span class="gc-file-path">{{ f.path }}</span>
</div>
</div>
<!-- Diff 预览 -->
<div v-if="commitSelectedFile && commitDiff" class="gc-diff-content">
<div v-for="(ln, idx) in commitDiffLines" :key="idx" class="diff-line" :class="'diff-' + ln.type">
<span class="diff-hdr-text" v-if="ln.type === 'hdr'">{{ ln.text }}</span>
<template v-else>
<span class="diff-line-prefix">{{ ln.prefix }}</span>
<span class="diff-line-text">{{ ln.text }}</span>
</template>
</div>
</div>
</template>
</div>
<!-- 提交列表 -->
<div class="gc-history-list">
<div
v-for="c in commits"
:key="c.hash"
class="gc-commit-row"
:class="{ selected: selectedCommit?.hash === c.hash }"
@click="selectCommit(c)"
>
<span class="gc-commit-hash">{{ c.hash.slice(0, 8) }}</span>
<span class="gc-commit-msg">{{ c.subject }}</span>
<span class="gc-commit-time">{{ formatTime(c.timestamp) }}</span>
</div>
</div>
<!-- 加载更多 -->
<div v-if="hasMoreCommits && !loadingMore" class="gc-load-more" @click="loadMoreCommits">
{{ $t('gitChanges.loadMore') }}
</div>
<div v-if="loadingMore" class="gc-loading"><span class="spinner"></span></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
/**
* Git 变更查看器 — 变更文件列表(按目录树分组) + 提交历史(分页) + 文件 diff 预览。
*/
import { ref, computed, watch } from 'vue'
import { moduleApi, type GitStatusResult } from '@/api/module'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps<{
moduleId: string
}>()
const emit = defineEmits<{
(e: 'select-file', path: string): void
}>()
const loading = ref(false)
const gitStatus = ref<GitStatusResult | null>(null)
const showHistory = ref(false)
const selectedFile = ref('')
const diffContent = ref('')
const diffLoading = ref(false)
// 提交历史(分页)
const commits = ref<{ hash: string; subject: string; timestamp: number }[]>([])
const totalCommits = ref(0)
const hasMoreCommits = ref(false)
const loadingMore = ref(false)
const commitSkip = ref(0)
const COMMIT_PAGE_SIZE = 50
// 已选提交详情
const selectedCommit = ref<{ hash: string; subject: string; timestamp: number } | null>(null)
const commitDetail = ref<{ files: { status: string; path: string }[]; diff: string } | null>(null)
const commitDetailLoading = ref(false)
const commitSelectedFile = ref('')
const commitDiff = ref('')
const branchName = computed(() => gitStatus.value?.branch || '')
interface FileGroup {
key: string
label: string
files: { path: string; status: string }[]
}
function statusKey(s: string): string {
const x = s.trim()
if (x.startsWith('M') || x === 'R') return 'modified'
if (x.startsWith('A')) return 'added'
if (x.startsWith('D')) return 'deleted'
if (x === '??') return 'untracked'
return 'modified'
}
/** 按状态分组 */
const groupedChanges = computed<FileGroup[]>(() => {
if (!gitStatus.value) return []
const groups: FileGroup[] = [
{ key: 'modified', label: t('gitChanges.modified'), files: [] },
{ key: 'added', label: t('gitChanges.added'), files: [] },
{ key: 'deleted', label: t('gitChanges.deleted'), files: [] },
{ key: 'untracked', label: t('gitChanges.untracked'), files: [] },
]
for (const f of gitStatus.value.changed_files) {
const s = f.status.trim()
if (s.startsWith('M') || s === 'R') groups[0].files.push(f)
else if (s.startsWith('A')) groups[1].files.push(f)
else if (s.startsWith('D')) groups[2].files.push(f)
else if (s === '??') groups[3].files.push(f)
else groups[0].files.push(f)
}
return groups.filter(g => g.files.length > 0)
})
interface DiffLine {
type: 'add' | 'del' | 'ctx' | 'hdr'
prefix: string
text: string
}
const commitDiffLines = computed<DiffLine[]>(() => parseDiff(commitDiff.value))
function parseDiff(text: string): DiffLine[] {
if (!text) return []
const lines: DiffLine[] = []
for (const raw of text.split('\n')) {
if (raw.startsWith('@@')) {
lines.push({ type: 'hdr', prefix: '', text: raw })
} else if (raw.startsWith('+')) {
lines.push({ type: 'add', prefix: '+', text: raw.slice(1) })
} else if (raw.startsWith('-')) {
lines.push({ type: 'del', prefix: '-', text: raw.slice(1) })
} else if (raw.startsWith('\\')) {
continue
} else {
lines.push({ type: 'ctx', prefix: ' ', text: raw })
}
}
return lines
}
function statusLabel(s: string): string {
const x = s.trim()
if (x === '??') return 'U'
if (x.startsWith('A')) return 'A'
if (x.startsWith('M')) return 'M'
if (x.startsWith('D')) return 'D'
if (x.startsWith('R')) return 'R'
return x.charAt(0) || '?'
}
/** 格式化 Unix 时间戳为相对时间或具体时间 */
function formatTime(ts: number): string {
if (!ts) return ''
const now = Math.floor(Date.now() / 1000)
const diffSec = now - ts
if (diffSec < 60) return t('common.justNow')
if (diffSec < 3600) return t('common.minutesAgo', { n: Math.floor(diffSec / 60) })
if (diffSec < 86400) return t('common.hoursAgo', { n: Math.floor(diffSec / 3600) })
if (diffSec < 172800) return t('common.yesterday')
if (diffSec < 2592000) return t('common.dayAgo', { n: Math.floor(diffSec / 86400) })
// 超过 30 天显示具体日期
const d = new Date(ts * 1000)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
/** 计算文件路径的目录深度(用于缩进)。root 级文件返回 0。 */
function filePathDepth(path: string): number {
const segs = path.split('/').filter(Boolean)
return Math.max(0, segs.length - 1)
}
/** 提取文件名(最后一段)。 */
function filePathLeaf(path: string): string {
const segs = path.split('/').filter(Boolean)
return segs[segs.length - 1] || path
}
async function loadStatus() {
if (!props.moduleId) return
loading.value = true
try {
gitStatus.value = await moduleApi.getModuleGitStatus(props.moduleId)
// 同时初始化前 50 条提交
const res = await moduleApi.getModuleCommits(props.moduleId, 0, COMMIT_PAGE_SIZE)
commits.value = res.commits
hasMoreCommits.value = res.has_more
totalCommits.value = res.commits.length
commitSkip.value = res.commits.length
} catch {
gitStatus.value = null
commits.value = []
} finally {
loading.value = false
}
}
async function loadMoreCommits() {
if (loadingMore.value || !hasMoreCommits.value) return
loadingMore.value = true
try {
const res = await moduleApi.getModuleCommits(props.moduleId, commitSkip.value, COMMIT_PAGE_SIZE)
commits.value.push(...res.commits)
hasMoreCommits.value = res.has_more
commitSkip.value = commits.value.length
totalCommits.value = commits.value.length
} catch {
// 静默失败
} finally {
loadingMore.value = false
}
}
async function switchToHistory() {
showHistory.value = true
// 如果只加载了初始数量,尝试补页
if (hasMoreCommits.value && commits.value.length < 50) {
await loadMoreCommits()
}
}
async function selectFile(path: string) {
selectedFile.value = path
emit('select-file', path)
diffContent.value = ''
diffLoading.value = true
try {
const res = await moduleApi.getModuleFileDiff(props.moduleId, path)
diffContent.value = res.diff || ''
} catch {
diffContent.value = ''
} finally {
diffLoading.value = false
}
}
/** 点击提交行 → 加载该提交的变更文件列表 */
async function selectCommit(c: { hash: string; subject: string; timestamp: number }) {
selectedCommit.value = c
commitDetail.value = null
commitSelectedFile.value = ''
commitDiff.value = ''
commitDetailLoading.value = true
try {
const res = await moduleApi.getCommitDetail(props.moduleId, c.hash)
commitDetail.value = res
} catch {
commitDetail.value = null
} finally {
commitDetailLoading.value = false
}
}
/** 在提交详情中点击文件 → 过滤出该文件的 diff */
function showCommitFileDiff(path: string) {
commitSelectedFile.value = path
if (!commitDetail.value) return
// 从全量 diff 中提取该文件的块
const diff = commitDetail.value.diff
const lines = diff.split('\n')
let inTarget = false
const targetLines: string[] = []
for (const line of lines) {
if (line.startsWith('diff --git')) {
inTarget = line.includes(path)
}
if (inTarget) {
targetLines.push(line)
}
}
commitDiff.value = targetLines.join('\n')
}
watch(() => props.moduleId, loadStatus, { immediate: true })
</script>
<style scoped>
.git-changes {
display: flex;
flex-direction: column;
height: 100%;
font-size: 13px;
color: var(--df-text);
}
.git-branch-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-bottom: 0.5px solid var(--df-border);
background: var(--df-bg-card);
flex-shrink: 0;
}
.branch-icon { font-size: 16px; }
.branch-name { font-weight: 600; color: var(--df-accent); }
.branch-count { margin-left: auto; font-size: 12px; color: var(--df-text-dim); }
/* Tabs */
.gc-tabs { display: flex; border-bottom: 0.5px solid var(--df-border); flex-shrink: 0; }
.gc-tab {
flex: 1; padding: 8px; background: none; border: none;
border-bottom: 2px solid transparent; color: var(--df-text-dim);
cursor: pointer; font-size: 12px; transition: all 0.15s;
}
.gc-tab.active { color: var(--df-text); border-bottom-color: var(--df-accent); }
.gc-tab:hover { color: var(--df-text); }
.gc-body { flex: 1; overflow-y: auto; overflow-x: hidden; min-height: 0; scrollbar-width: thin; }
.gc-changes { min-height: 0; }
/* 分组 */
.gc-group-header {
padding: 6px 14px; font-size: 11px; font-weight: 600;
color: var(--df-text-dim); text-transform: uppercase; letter-spacing: 0.5px;
background: rgba(255,255,255,0.02); border-bottom: 0.5px solid var(--df-border);
position: sticky; top: 0; z-index: 1;
}
/* Diff 预览 */
.gc-diff-content {
font-family: var(--df-font-mono, Consolas, monospace);
font-size: 12px; line-height: 1.5; overflow: auto; scrollbar-width: thin;
}
.diff-line { display: flex; padding: 0 14px; }
.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-hdr-text {
padding: 4px 14px; background: rgba(60,140,220,0.08);
color: var(--df-text-dim); font-weight: 500; font-size: 11px; display: block;
}
.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-ctx { color: var(--df-text); }
/* 提交历史 */
.gc-history-wrapper { display: flex; flex-direction: column; }
.gc-commit-row {
display: flex; align-items: center; gap: 10px;
padding: 8px 14px; border-bottom: 0.5px solid rgba(255,255,255,0.03);
cursor: pointer; transition: background 0.1s;
}
.gc-commit-row:hover { background: rgba(255,255,255,0.04); }
.gc-commit-row.selected { background: rgba(255,255,255,0.06); }
.gc-commit-hash {
font-family: var(--df-font-mono, Consolas, monospace);
font-size: 11px; color: var(--df-accent); flex-shrink: 0;
}
.gc-commit-msg { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.gc-commit-time { font-size: 11px; color: var(--df-text-dim); flex-shrink: 0; }
/* 提交详情 */
.gc-commit-detail { border-bottom: 0.5px solid var(--df-border); }
.gc-commit-detail-header {
display: flex; align-items: center; gap: 10px;
padding: 8px 14px; background: rgba(255,255,255,0.03);
position: sticky; top: 0; z-index: 1;
}
.gc-commit-detail-hash {
font-family: var(--df-font-mono, Consolas, monospace);
font-size: 11px; color: var(--df-accent);
}
.gc-commit-detail-msg { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.gc-commit-detail-time { font-size: 11px; color: var(--df-text-dim); flex-shrink: 0; }
.gc-detail-close {
background: none; border: none; color: var(--df-text-dim);
cursor: pointer; font-size: 14px; padding: 0 4px;
}
/* 提交的文件列表 */
.gc-commit-files { border-bottom: 0.5px solid var(--df-border); }
.gc-commit-file-row {
display: flex; align-items: center; gap: 8px;
padding: 5px 14px 5px 28px; cursor: pointer; transition: background 0.1s;
border-bottom: 0.5px solid rgba(255,255,255,0.02);
}
.gc-commit-file-row:hover { background: rgba(255,255,255,0.04); }
.gc-commit-file-row.active { background: rgba(255,255,255,0.06); }
.gc-file-status { width: 20px; text-align: center; font-size: 11px; font-weight: 700; flex-shrink: 0; }
.gc-st-modified { color: #f0a020; }
.gc-st-added { color: #4caf50; }
.gc-st-deleted { color: #e05050; }
.gc-st-untracked { color: #999; }
.gc-file-path {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-family: var(--df-font-mono, Consolas, monospace); font-size: 12px;
}
.gc-file-name {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-family: var(--df-font-mono, Consolas, monospace); font-size: 12px;
}
/* 加载更多 */
.gc-load-more {
text-align: center; padding: 12px; cursor: pointer;
color: var(--df-accent); font-size: 12px;
transition: background 0.1s;
}
.gc-load-more:hover { background: rgba(255,255,255,0.04); }
/* 通用 */
.gc-loading, .gc-empty {
display: flex; justify-content: center; align-items: center;
padding: 40px 0; color: var(--df-text-dim); font-size: 13px;
}
.gc-diff-loading { display: flex; justify-content: center; padding: 20px; }
.gc-diff-empty { padding: 20px 14px; color: var(--df-text-dim); font-size: 12px; text-align: center; }
.spinner {
width: 16px; height: 16px;
border: 2px solid var(--df-border); border-top-color: var(--df-accent);
border-radius: 50%; animation: df-spin 0.8s linear infinite; display: inline-block;
}
@keyframes df-spin { to { transform: rotate(360deg); } }
</style>

View File

@@ -10,15 +10,15 @@
import { ref, reactive, computed, watch, onBeforeUnmount, type Ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useConfirm } from '@/composables/useConfirm'
import { STREAM_TIMEOUT_MS } from '@/composables/ai/useAiStream'
import type { AiToolCallInfo } from '@/api/types'
/**
* 审批按钮 loading 超时兜底(B-260616-08)。
* 后端回事件使 tc.status 离开 pending_approval → watch 复位。
* 兜底计时到点复位 + toast 提示(对齐全局看门狗 STREAM_TIMEOUT_MS,后端无回执时给用户重试入口)。
* 兜底计时到点复位 + toast 提示(后端 IPC 应在 1~3s 内返回;30s 兜底远足超时后允许用户重试)。
* 与 stream 看门狗(130s)解耦:审批是按钮级交互,不是后台流,不需要对齐后端 120s 空闲超时。
*/
const APPROVE_LOADING_TIMEOUT_MS = STREAM_TIMEOUT_MS
const APPROVE_LOADING_TIMEOUT_MS = 30_000
/** AE-2025-05:High 风险工具白名单(后端 tool_registry.rs RiskLevel::High 对齐) */
const HIGH_RISK_TOOLS = new Set<string>([

View File

@@ -0,0 +1,76 @@
/**
* 文件浏览器窗口分离 — 弹出/分离/附加 FileExplorer 独立窗口。
*
* 对标 useAiWindow 的 detach/reattach 模式,复用 WebviewWindow API。
* 分离窗口以独立 Tauri 窗口打开 FileExplorerDetached 视图,
* 通过 URL query `?projectId=xxx` 传参。
*/
const DETACHED_LABEL_PREFIX = 'fe-detached-'
function detachedLabel(projectId: string): string {
return DETACHED_LABEL_PREFIX + projectId
}
/**
* 将文件浏览器弹出到独立窗口。
* 若该项目的窗口已存在则聚焦,不重复创建。
*
* @param projectId 项目 id(传给分离窗口加载工程列表)
* @param title 窗口标题(可选,默认"文件浏览器")
*/
export async function detachFileExplorer(
projectId: string,
title?: string,
): Promise<void> {
if (!projectId) return
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
const label = detachedLabel(projectId)
// 已存在则聚焦
const existing = await WebviewWindow.getByLabel(label)
if (existing) {
await existing.setFocus()
return
}
const url =
window.location.origin +
window.location.pathname +
`#/file-explorer-detached?projectId=${encodeURIComponent(projectId)}`
const win = new WebviewWindow(label, {
url,
title: title || '文件浏览器',
width: 900,
height: 600,
minWidth: 640,
minHeight: 400,
center: true,
decorations: true,
})
win.once('tauri://created', () => {
console.log('[FileExplorer] 分离窗口已创建:', label)
})
win.once('tauri://error', (e) => {
console.error('[FileExplorer] 窗口创建失败:', e)
})
}
/**
* 关闭指定项目的文件浏览器分离窗口。
*/
export async function reattachFileExplorer(projectId: string): Promise<void> {
if (!projectId) return
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
const label = detachedLabel(projectId)
const existing = await WebviewWindow.getByLabel(label)
if (existing) {
try {
await existing.close()
} catch {
// 忽略关闭失败
}
}
}

View File

@@ -5,6 +5,8 @@ export default {
// Toolbar
refresh: 'Refresh',
refreshing: 'Refreshing…',
// Detach window
detachTitle: 'File Explorer',
// File tree
loading: 'Loading…',
emptyDir: 'Empty directory',
@@ -15,5 +17,17 @@ export default {
truncated: 'File too large, only first 1MB shown',
// Errors
loadFailed: 'Failed to load',
// Diff
showDiff: 'Show Diff',
showContent: 'Show Content',
// Module management
addModule: 'Add Module',
adding: 'Adding…',
moduleName: 'Module Name',
moduleNamePlaceholder: 'e.g. frontend, backend',
modulePath: 'Directory Path',
modulePathPlaceholder: 'e.g. D:/projects/my-app/frontend',
moduleGitUrl: 'Git URL',
moduleGitUrlPlaceholder: 'https://github.com/… (optional)',
},
}

17
src/i18n/en/gitChanges.ts Normal file
View File

@@ -0,0 +1,17 @@
export default {
gitChanges: {
changes: 'Changes',
history: 'History',
changedFiles: 'changed',
noChanges: 'No changes (clean working tree)',
noDiff: 'No diff available',
noCommits: 'No commits yet',
modified: 'Modified',
added: 'Added',
deleted: 'Deleted',
untracked: 'Untracked',
notGitRepo: 'Not a git repository',
detached: 'HEAD detached',
loadMore: 'Load more',
},
}

View File

@@ -5,6 +5,8 @@ export default {
// 工具栏
refresh: '刷新',
refreshing: '刷新中…',
// 分离窗口
detachTitle: '文件浏览器',
// 文件树
loading: '加载中…',
emptyDir: '空目录',
@@ -15,5 +17,19 @@ export default {
truncated: '文件过大,仅显示前 1MB',
// 错误
loadFailed: '加载失败',
// Diff
showDiff: '查看变更',
showContent: '查看内容',
// 工程管理
addModule: '添加工程',
adding: '添加中…',
moduleName: '工程名称',
moduleNamePlaceholder: '例如 frontend、backend',
modulePath: '目录路径',
modulePathPlaceholder: '例如 D:/projects/my-app/frontend',
moduleGitUrl: 'Git 地址',
moduleGitUrlPlaceholder: 'https://github.com/…(可选)',
// Git Changes tab
loadMore: '加载更多',
},
}

View File

@@ -0,0 +1,17 @@
export default {
gitChanges: {
changes: '变更',
history: '历史',
changedFiles: '个变更',
noChanges: '无变更(工作区干净)',
noDiff: '无差异内容',
noCommits: '暂无提交记录',
modified: '已修改',
added: '已新增',
deleted: '已删除',
untracked: '未跟踪',
notGitRepo: '非 Git 仓库',
detached: 'HEAD 分离',
loadMore: '加载更多',
},
}

View File

@@ -77,6 +77,12 @@ const routes = [
component: () => import('../views/AiDetached.vue'),
meta: { title: 'AI Chat' },
},
{
path: '/file-explorer-detached',
name: 'FileExplorerDetached',
component: () => import('../views/FileExplorerDetached.vue'),
meta: { title: '文件浏览器' },
},
// catch-all:无效路由重定向首页(防白屏)
{
path: '/:pathMatch(.*)*',

View File

@@ -0,0 +1,55 @@
<template>
<div class="fe-detached-shell">
<!-- 等待 projectId 参数加载 -->
<div v-if="!projectId" class="fe-detached-loading">
<span class="spinner"></span>
</div>
<FileExplorer v-else :project-id="projectId" />
</div>
</template>
<script setup lang="ts">
/**
* 文件浏览器分离窗口视图(Batch 12)。
* 作为独立 Tauri 窗口的全屏内容,通过 URL query `?projectId=xxx` 接收参数。
*/
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import FileExplorer from '@/components/project/FileExplorer.vue'
const route = useRoute()
const projectId = computed(() => (route.query.projectId as string) || '')
</script>
<style scoped>
.fe-detached-shell {
width: 100vw;
height: 100vh;
overflow: hidden;
background: var(--df-bg);
}
.fe-detached-loading {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
color: var(--df-text-dim);
font-size: 14px;
}
.spinner {
width: 20px;
height: 20px;
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;
margin-right: 8px;
}
@keyframes df-spin {
to { transform: rotate(360deg); }
}
</style>

View File

@@ -38,7 +38,8 @@
</div>
</div>
<!-- 阶段进度条 -->
<!-- 阶段进度条(暂时隐藏:阶段流转逻辑未接通,后续接入真实状态机后启用) -->
<!--
<div class="stage-pipeline">
<div
v-for="(stage, idx) in stages"
@@ -57,6 +58,7 @@
<div v-if="idx < stages.length - 1" class="stage-connector" :class="{ 'connector-done': idx < currentStageIndex }"></div>
</div>
</div>
-->
<!-- Tab 导航(概览 / 文件浏览器) -->
<nav class="detail-tabs">
@@ -272,7 +274,7 @@ import { projectApi } from '@/api'
import { formatDate } from '@/utils/time'
import { parseStack } from '@/utils/project'
import { parseScores as parseScoresJson, assessmentClass, assessmentLabel as assessmentLabelI18n } from '@/utils/ideaEval'
import { projectStatusLabel, projectStageInfo, taskStatusLabel, taskStatusClass } from '../constants/project'
import { projectStatusLabel, taskStatusLabel, taskStatusClass } from '../constants/project'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import ApprovalDialog from '@/components/project/ApprovalDialog.vue'
import FileExplorer from '@/components/project/FileExplorer.vue'
@@ -293,14 +295,14 @@ const activeTab = ref<'overview' | 'files'>('overview')
// 确认弹层状态机抽至 composables/useConfirm(原 4 视图重复:Projects/ProjectDetail/Ideas/Settings)
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
// ── 阶段定义(labelKey 对应 i18n projectDetail.stage*) ──
const stages = [
{ key: 'idea', labelKey: 'stageIdea' },
{ key: 'requirement', labelKey: 'stageRequirement' },
{ key: 'coding', labelKey: 'stageCoding' },
{ key: 'testing', labelKey: 'stageTesting' },
{ key: 'release', labelKey: 'stageRelease' },
]
// ── 阶段定义(暂时隐藏,阶段进度条模板已注释,后续接入真实状态机后启用) ──
// const stages = [
// { key: 'idea', labelKey: 'stageIdea' },
// { key: 'requirement', labelKey: 'stageRequirement' },
// { key: 'coding', labelKey: 'stageCoding' },
// { key: 'testing', labelKey: 'stageTesting' },
// { key: 'release', labelKey: 'stageRelease' },
// ]
// ── 当前项目 ──
// 状态文案/阶段进度统一走 ../constants/project(与 Projects/Tasks/Dashboard 一致,
@@ -309,6 +311,8 @@ const projectId = computed(() => route.params.id as string)
const currentProject = computed(() =>
store.projects.find(p => p.id === projectId.value)
)
// 状态文案/阶段进度统一走 ../constants/project
// B-260615-25:项目描述 Markdown 渲染(复用 AiChat/TaskDetail 同款渲染器,模块级单例),
// useRendered 封装 computed(读 mdReady 触发响应式 + renderMd)+ ensureLoaded(幂等预热)
const { rendered: renderedDesc, ensureLoaded } = useRendered(
@@ -316,7 +320,7 @@ const { rendered: renderedDesc, ensureLoaded } = useRendered(
)
const stageKey = computed(() => currentProject.value?.status ?? 'planning')
const statusLabel = computed(() => projectStatusLabel(stageKey.value))
const currentStageIndex = computed(() => projectStageInfo(stageKey.value).stepIndex)
// const currentStageIndex = computed(() => projectStageInfo(stageKey.value).stepIndex)
// ── 来源灵感(晋升携带评估结论回溯)──
// ProjectRecord.idea_id 存灵感 id(promote_idea 写入)。
@@ -685,7 +689,7 @@ onUnmounted(() => {
</style>
<style scoped>
.project-detail { padding: 16px 20px 20px; }
.project-detail { padding: 16px 20px 20px; display: flex; flex-direction: column; min-height: 0; flex: 1; }
/* ===== Tab 导航(Batch 10 文件浏览器) ===== */
.detail-tabs {
@@ -713,10 +717,11 @@ onUnmounted(() => {
border-bottom-color: var(--df-accent);
}
/* 文件浏览器容器(占满高度,内部 FileExplorer 自管布局) */
/* 文件浏览器容器(弹性填充,内部 FileExplorer 自管布局) */
.file-explorer-wrap {
height: calc(100vh - 340px);
min-height: 480px;
flex: 1;
min-height: 0;
overflow: hidden;
}
.page-header {