Files
Hermes-ui/packages/client/src/stores/hermes/files.ts
T

242 lines
7.8 KiB
TypeScript
Raw Normal View History

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import * as filesApi from '@/api/hermes/files'
import type { FileEntry } from '@/api/hermes/files'
const EXT_LANG_MAP: Record<string, string> = {
'.js': 'javascript', '.jsx': 'javascript',
'.ts': 'typescript', '.tsx': 'typescript',
'.json': 'json', '.jsonc': 'json',
'.html': 'html', '.htm': 'html',
'.css': 'css', '.scss': 'scss', '.less': 'less',
'.md': 'markdown', '.markdown': 'markdown',
'.py': 'python',
'.yaml': 'yaml', '.yml': 'yaml',
'.xml': 'xml',
'.sh': 'shell', '.bash': 'shell', '.zsh': 'shell',
'.sql': 'sql',
'.go': 'go',
'.rs': 'rust',
'.java': 'java',
'.c': 'c', '.h': 'c',
'.cpp': 'cpp', '.hpp': 'cpp',
'.toml': 'ini',
'.ini': 'ini',
'.env': 'ini',
'.vue': 'html',
'.dockerfile': 'dockerfile',
'.graphql': 'graphql',
'.lua': 'lua',
'.r': 'r',
'.rb': 'ruby',
'.php': 'php',
'.swift': 'swift',
'.kt': 'kotlin',
}
function getLanguageFromPath(filePath: string): string {
const name = filePath.split('/').pop() || ''
if (name === 'Dockerfile') return 'dockerfile'
if (name === 'Makefile') return 'makefile'
const ext = '.' + name.split('.').pop()?.toLowerCase()
return EXT_LANG_MAP[ext] || 'plaintext'
}
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp', '.ico'])
function getFileExt(name: string): string {
const idx = name.lastIndexOf('.')
return idx >= 0 ? name.slice(idx).toLowerCase() : ''
}
export function isImageFile(name: string): boolean {
return IMAGE_EXTS.has(getFileExt(name))
}
export function isMarkdownFile(name: string): boolean {
const ext = getFileExt(name)
return ext === '.md' || ext === '.markdown'
}
export function isTextFile(name: string): boolean {
const binaryExts = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.ico', '.zip', '.gz', '.tar', '.7z', '.rar', '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.mp3', '.mp4', '.wav', '.webm', '.avi', '.mov', '.exe', '.dll', '.so', '.dylib', '.bin', '.dat', '.db', '.sqlite'])
return !binaryExts.has(getFileExt(name))
}
// Returns true if `targetPath` is the same as `changedPath` or lives inside it
// when `changedIsDir` is true. Used to invalidate preview/editor state when
// the underlying file is deleted or renamed.
function isAffected(targetPath: string, changedPath: string, changedIsDir: boolean): boolean {
if (targetPath === changedPath) return true
if (changedIsDir && targetPath.startsWith(changedPath + '/')) return true
return false
}
export const useFilesStore = defineStore('files', () => {
const currentPath = ref('')
const entries = ref<FileEntry[]>([])
const loading = ref(false)
const sortBy = ref<'name' | 'size' | 'modTime'>('name')
const sortOrder = ref<'asc' | 'desc'>('asc')
const editingFile = ref<{
path: string
content: string
originalContent: string
language: string
} | null>(null)
const previewFile = ref<{
path: string
type: 'image' | 'markdown'
content?: string
} | null>(null)
const pathSegments = computed(() => {
if (!currentPath.value) return []
return currentPath.value.split('/').filter(Boolean)
})
const sortedEntries = computed(() => {
const copy = [...entries.value]
copy.sort((a, b) => {
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1
let cmp = 0
switch (sortBy.value) {
case 'name': cmp = a.name.localeCompare(b.name); break
case 'size': cmp = a.size - b.size; break
case 'modTime': cmp = a.modTime.localeCompare(b.modTime); break
}
return sortOrder.value === 'asc' ? cmp : -cmp
})
return copy
})
async function fetchEntries(path?: string) {
if (path !== undefined && path !== currentPath.value) {
// Switching directory invalidates the current preview; close it so the
// file list becomes visible again. The editor has its own dirty-check
// (see hasUnsavedChanges), so we leave editingFile alone here.
previewFile.value = null
}
if (path !== undefined) currentPath.value = path
loading.value = true
try {
const result = await filesApi.listFiles(currentPath.value)
entries.value = result.entries
} catch (err) {
console.error('Failed to fetch files:', err)
throw err
} finally {
loading.value = false
}
}
function navigateTo(path: string) { return fetchEntries(path) }
function navigateUp() {
const parts = currentPath.value.split('/').filter(Boolean)
parts.pop()
return fetchEntries(parts.join('/'))
}
async function openEditor(filePath: string) {
const result = await filesApi.readFile(filePath)
editingFile.value = {
path: filePath,
content: result.content,
originalContent: result.content,
language: getLanguageFromPath(filePath),
}
}
async function saveEditor() {
if (!editingFile.value) return
await filesApi.writeFile(editingFile.value.path, editingFile.value.content)
editingFile.value.originalContent = editingFile.value.content
}
function closeEditor() { editingFile.value = null }
async function openPreview(entry: FileEntry) {
if (isImageFile(entry.name)) {
previewFile.value = { path: entry.path, type: 'image' }
} else if (isMarkdownFile(entry.name)) {
const result = await filesApi.readFile(entry.path)
previewFile.value = { path: entry.path, type: 'markdown', content: result.content }
}
}
function closePreview() { previewFile.value = null }
async function createDir(name: string) {
const path = currentPath.value ? `${currentPath.value}/${name}` : name
await filesApi.mkDir(path)
await fetchEntries()
}
async function createFile(name: string) {
const path = currentPath.value ? `${currentPath.value}/${name}` : name
await filesApi.writeFile(path, '')
await fetchEntries()
}
async function deleteEntry(entry: FileEntry) {
await filesApi.deleteFile(entry.path, entry.isDir)
if (previewFile.value && isAffected(previewFile.value.path, entry.path, entry.isDir)) {
previewFile.value = null
}
if (editingFile.value && isAffected(editingFile.value.path, entry.path, entry.isDir)) {
editingFile.value = null
}
await fetchEntries()
}
async function renameEntry(entry: FileEntry, newName: string) {
const parentPath = entry.path.includes('/') ? entry.path.slice(0, entry.path.lastIndexOf('/')) : ''
const newPath = parentPath ? `${parentPath}/${newName}` : newName
await filesApi.renameFile(entry.path, newPath)
if (previewFile.value && isAffected(previewFile.value.path, entry.path, entry.isDir)) {
previewFile.value = null
}
if (editingFile.value && isAffected(editingFile.value.path, entry.path, entry.isDir)) {
editingFile.value = null
}
await fetchEntries()
}
async function copyEntry(entry: FileEntry, destPath: string) {
await filesApi.copyFile(entry.path, destPath)
await fetchEntries()
}
async function uploadFiles(files: File[]) {
await filesApi.uploadFiles(currentPath.value, files)
await fetchEntries()
}
function setSort(by: 'name' | 'size' | 'modTime') {
if (sortBy.value === by) {
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
} else {
sortBy.value = by
sortOrder.value = 'asc'
}
}
const hasUnsavedChanges = computed(() => {
if (!editingFile.value) return false
return editingFile.value.content !== editingFile.value.originalContent
})
return {
currentPath, entries, loading, sortBy, sortOrder,
editingFile, previewFile,
pathSegments, sortedEntries, hasUnsavedChanges,
fetchEntries, navigateTo, navigateUp,
openEditor, saveEditor, closeEditor,
openPreview, closePreview,
createDir, createFile, deleteEntry, renameEntry, copyEntry,
uploadFiles, setSort,
}
})