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:
ww
2026-04-23 12:09:39 +08:00
committed by GitHub
parent 1f91b902da
commit 0cc31ee999
32 changed files with 2913 additions and 12 deletions
+214
View File
@@ -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,
}
})