新增: 文件浏览器增强(窗口分离/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:
@@ -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(刷新按钮内) */
|
||||
|
||||
Reference in New Issue
Block a user