nanoClaw/frontend/src/components/FileExplorer.vue

486 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="file-explorer">
<!-- File tree sidebar -->
<div class="explorer-sidebar">
<div class="explorer-header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
<span class="explorer-title">{{ projectName }}</span>
<div class="explorer-actions">
<button class="btn-icon-sm" @click="createNewFile" title="新建文件">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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="12" y1="11" x2="12" y2="17"/>
<line x1="9" y1="14" x2="15" y2="14"/>
</svg>
</button>
<button class="btn-icon-sm" @click="createNewFolder" title="新建文件夹">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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"/>
<line x1="12" y1="11" x2="12" y2="17"/>
<line x1="9" y1="14" x2="15" y2="14"/>
</svg>
</button>
</div>
</div>
<div v-if="loadingTree" class="explorer-loading">
<svg class="spinner" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>
</div>
<div v-else-if="treeItems.length === 0" class="explorer-empty">
空项目
</div>
<div v-else class="tree-container">
<FileTreeItem
v-for="item in treeItems"
:key="item.path"
:item="item"
:active-path="activeFile"
:depth="0"
:project-id="projectId"
@select="openFile"
@refresh="loadTree"
/>
</div>
</div>
<!-- File viewer panel -->
<div v-if="activeFile" class="file-viewer">
<div class="viewer-header">
<div class="viewer-breadcrumb">
<span v-for="(seg, i) in breadcrumbSegments" :key="i" class="breadcrumb-seg">
{{ seg }}
<span v-if="i < breadcrumbSegments.length - 1" class="breadcrumb-sep">/</span>
</span>
</div>
<div class="viewer-actions">
<button class="btn-icon-sm" @click="activeFile = null" title="关闭">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
</div>
<div v-if="loadingFile" class="viewer-loading">
<svg class="spinner" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>
加载中...
</div>
<div v-else-if="fileError" class="viewer-error">
<span>{{ fileError }}</span>
</div>
<!-- Text / code editor (all non-image files including .md) -->
<div v-else-if="fileType !== 'image'" class="code-pane">
<CodeEditor
v-model="editContent"
:filename="activeFile"
:dark="isDark"
@save="saveFile"
/>
</div>
<!-- Image viewer -->
<div v-else class="image-viewer">
<img :src="imageUrl" :alt="activeFile" />
</div>
</div>
<!-- Empty state -->
<div v-else class="viewer-placeholder">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" style="color: var(--text-tertiary); opacity: 0.5;">
<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>选择文件以预览</span>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { projectApi } from '../api'
import FileTreeItem from './FileTreeItem.vue'
import CodeEditor from './CodeEditor.vue'
import { normalizeFileTree } from '../utils/fileTree'
import { useTheme } from '../composables/useTheme'
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico'])
const props = defineProps({
projectId: { type: String, required: true },
projectName: { type: String, default: '' },
})
const { isDark } = useTheme()
// -- Tree state --
const treeItems = ref([])
const loadingTree = ref(false)
// -- Viewer state --
const activeFile = ref(null)
const fileError = ref('')
const loadingFile = ref(false)
const editContent = ref('')
const saving = ref(false)
const imageUrl = ref('')
function releaseImageUrl() {
if (imageUrl.value) {
URL.revokeObjectURL(imageUrl.value)
imageUrl.value = ''
}
}
const fileExt = computed(() => {
if (!activeFile.value) return ''
const parts = activeFile.value.split('.')
return parts.length > 1 ? parts.pop().toLowerCase() : ''
})
const breadcrumbSegments = computed(() => {
if (!activeFile.value) return []
return activeFile.value.split('/')
})
const fileType = computed(() => {
if (IMAGE_EXTS.has(fileExt.value)) return 'image'
return 'text'
})
async function loadTree(path = '') {
loadingTree.value = true
try {
const res = await projectApi.listFiles(props.projectId, path)
treeItems.value = normalizeFileTree(res.data, { expanded: false })
} catch (e) {
console.error('Failed to load tree:', e)
} finally {
loadingTree.value = false
}
}
async function openFile(filepath) {
activeFile.value = filepath
fileError.value = ''
editContent.value = ''
releaseImageUrl()
loadingFile.value = true
const ext = filepath.split('.').pop().toLowerCase()
if (IMAGE_EXTS.has(ext)) {
try {
const res = await projectApi.readFileRaw(props.projectId, filepath)
const blob = await res.blob()
imageUrl.value = URL.createObjectURL(blob)
} catch (e) {
fileError.value = '加载图片失败: ' + (e.message || '')
} finally {
loadingFile.value = false
}
return
}
try {
const res = await projectApi.readFile(props.projectId, filepath)
editContent.value = res.data.content
} catch (e) {
fileError.value = e.message || '加载文件失败'
} finally {
loadingFile.value = false
}
}
async function saveFile() {
if (!activeFile.value || saving.value) return
saving.value = true
try {
await projectApi.writeFile(props.projectId, activeFile.value, editContent.value)
} catch (e) {
alert('保存失败: ' + e.message)
} finally {
saving.value = false
}
}
function deleteFile() {
if (!activeFile.value) return
const name = activeFile.value.split('/').pop()
if (!confirm(`确定要删除 ${name} 吗?`)) return
projectApi.deleteFile(props.projectId, activeFile.value).then(() => {
activeFile.value = null
loadTree()
}).catch(e => {
alert('删除失败: ' + e.message)
})
}
async function createNewFile() {
const name = prompt('文件名(例如 utils.py')
if (!name?.trim()) return
const path = name.trim()
try {
await projectApi.writeFile(props.projectId, path, '')
await loadTree()
openFile(path)
} catch (e) {
alert('创建失败: ' + e.message)
}
}
async function createNewFolder() {
const name = prompt('文件夹名称')
if (!name?.trim()) return
try {
await projectApi.mkdir(props.projectId, name.trim())
await loadTree()
} catch (e) {
alert('创建失败: ' + e.message)
}
}
// Ctrl+S global shortcut
function onGlobalKeydown(e) {
if (e.key === 's' && (e.ctrlKey || e.metaKey) && activeFile.value) {
e.preventDefault()
saveFile()
}
}
watch(() => props.projectId, () => {
activeFile.value = null
editContent.value = ''
releaseImageUrl()
loadTree()
})
onMounted(() => {
loadTree()
document.addEventListener('keydown', onGlobalKeydown)
})
onUnmounted(() => {
document.removeEventListener('keydown', onGlobalKeydown)
releaseImageUrl()
})
</script>
<style scoped>
.file-explorer {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* -- Sidebar -- */
.explorer-sidebar {
width: 20%;
min-width: 140px;
flex-shrink: 0;
display: flex;
flex-direction: column;
overflow: hidden;
border-right: 1px solid var(--border-light);
background: var(--bg-secondary);
}
.explorer-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid var(--border-light);
font-size: 13px;
}
.explorer-header svg:first-child {
color: var(--text-tertiary);
flex-shrink: 0;
}
.explorer-title {
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.explorer-actions {
display: flex;
gap: 2px;
flex-shrink: 0;
}
.btn-icon-sm {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: none;
color: var(--text-tertiary);
cursor: pointer;
border-radius: 4px;
transition: all 0.15s;
}
.btn-icon-sm:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.btn-icon-sm.save:hover {
background: var(--success-bg);
color: var(--success-color);
}
.btn-icon-sm.danger:hover {
background: var(--danger-bg);
color: var(--danger-color);
}
.tree-container {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.tree-container::-webkit-scrollbar {
width: 4px;
}
.tree-container::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 2px;
}
.explorer-loading,
.explorer-empty {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
color: var(--text-tertiary);
font-size: 13px;
}
/* -- Viewer -- */
.file-viewer {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.viewer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--border-light);
background: var(--bg-secondary);
gap: 8px;
}
.viewer-breadcrumb {
display: flex;
align-items: center;
font-size: 12px;
color: var(--text-secondary);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.breadcrumb-seg {
flex-shrink: 0;
}
.breadcrumb-sep {
margin: 0 2px;
color: var(--text-tertiary);
}
.viewer-actions {
display: flex;
gap: 2px;
flex-shrink: 0;
align-items: center;
}
.viewer-loading {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
gap: 8px;
color: var(--text-tertiary);
font-size: 13px;
}
.viewer-error {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
color: var(--danger-color);
font-size: 13px;
}
.viewer-placeholder {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--text-tertiary);
font-size: 13px;
}
/* -- Code pane -- */
.code-pane {
flex: 1;
overflow: hidden;
}
/* -- Image viewer -- */
.image-viewer {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: var(--bg-code);
overflow: auto;
}
.image-viewer img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 4px;
}
</style>