feat: add file browser and file download with multi-backend support (#142)
* feat: add file browser and file download with multi-backend support Adds a built-in File Browser page and a File Download system to Hermes Web UI, enabling users to browse, edit, preview, upload, and download files from the workspace directly from the web dashboard. File Browser (/hermes/files): - New view FilesView.vue plus components under components/hermes/files/ (FileTree, FileList, FileBreadcrumb, FileToolbar, FileContextMenu, FileEditor, FilePreview, FileRenameModal, FileUploadModal) - New Pinia store stores/hermes/files.ts for directory tree, selection, and editing state - New API module api/hermes/files.ts - New server routes routes/hermes/files.ts with CRUD, rename, upload, and directory listing - New service services/hermes/file-provider.ts with a pluggable provider architecture (local filesystem + multi-terminal backends) File Download: - New server route routes/hermes/download.ts and client API api/hermes/download.ts - Integration in chat messages (MessageItem.vue, MarkdownRenderer.vue) to surface downloadable file references Packaging: - package.json: add a prepare script so the package can be installed directly from a git URL with dist/ built automatically i18n: add files/download translations to en.ts and zh.ts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: use clipboard fallback for non-secure HTTP contexts navigator.clipboard is undefined on HTTP intranet deployments (only available in secure contexts). The previous synchronous calls threw silently and the success toast still fired, making 'copy' actions appear broken. - Add packages/client/src/utils/clipboard.ts with execCommand fallback via a hidden textarea - Use the helper in FileContextMenu (copy file path), CodexLoginModal (copy user code), NousLoginModal (copy user code), ChatPanel (copy session id) - Each call now awaits the result and shows success/failure based on the actual outcome Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
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))
|
||||
}
|
||||
|
||||
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) 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)
|
||||
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)
|
||||
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,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user