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
@@ -0,0 +1,119 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useFilesStore } from '@/stores/hermes/files'
import FileTree from '@/components/hermes/files/FileTree.vue'
import FileBreadcrumb from '@/components/hermes/files/FileBreadcrumb.vue'
import FileToolbar from '@/components/hermes/files/FileToolbar.vue'
import FileList from '@/components/hermes/files/FileList.vue'
import FileContextMenu from '@/components/hermes/files/FileContextMenu.vue'
import FileEditor from '@/components/hermes/files/FileEditor.vue'
import FilePreview from '@/components/hermes/files/FilePreview.vue'
import FileUploadModal from '@/components/hermes/files/FileUploadModal.vue'
import FileRenameModal from '@/components/hermes/files/FileRenameModal.vue'
import type { FileEntry } from '@/api/hermes/files'
const filesStore = useFilesStore()
const contextMenuRef = ref<InstanceType<typeof FileContextMenu> | null>(null)
const showUpload = ref(false)
const showRenameModal = ref(false)
const renameMode = ref<'newFile' | 'newFolder' | 'rename'>('newFile')
const renameEntry = ref<FileEntry | null>(null)
function handleContextMenu(e: MouseEvent, entry: FileEntry) {
contextMenuRef.value?.show(e, entry)
}
function handleShowNewFile() {
renameMode.value = 'newFile'
renameEntry.value = null
showRenameModal.value = true
}
function handleShowNewFolder() {
renameMode.value = 'newFolder'
renameEntry.value = null
showRenameModal.value = true
}
function handleRename(entry: FileEntry) {
renameMode.value = 'rename'
renameEntry.value = entry
showRenameModal.value = true
}
onMounted(() => {
filesStore.fetchEntries('')
})
</script>
<template>
<div class="files-view">
<div class="files-tree-panel">
<FileTree />
</div>
<div class="files-main-panel">
<FileToolbar
@show-new-file="handleShowNewFile"
@show-new-folder="handleShowNewFolder"
@show-upload="showUpload = true"
/>
<FileBreadcrumb />
<div class="files-content">
<FileEditor v-if="filesStore.editingFile" />
<FilePreview v-else-if="filesStore.previewFile" />
<FileList v-else @contextmenu-entry="handleContextMenu" />
</div>
</div>
<FileContextMenu ref="contextMenuRef" @rename="handleRename" />
<FileUploadModal v-model:show="showUpload" />
<FileRenameModal v-model:show="showRenameModal" :mode="renameMode" :entry="renameEntry" />
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.files-view {
display: flex;
height: 100%;
overflow: hidden;
}
.files-tree-panel {
width: 240px;
min-width: 180px;
max-width: 400px;
border-right: 1px solid $border-color;
overflow-y: auto;
flex-shrink: 0;
}
.files-main-panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.files-content {
flex: 1;
overflow-y: auto;
min-height: 0;
}
@media (max-width: $breakpoint-mobile) {
.files-view {
flex-direction: column;
}
.files-tree-panel {
width: 100%;
max-width: none;
height: 200px;
border-right: none;
border-bottom: 1px solid $border-color;
}
}
</style>