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,36 @@
|
||||
import { getApiKey, getBaseUrlValue } from '../client'
|
||||
|
||||
/**
|
||||
* Construct a download URL with auth token as query parameter.
|
||||
* Token is passed via query param because <a> tags cannot set headers.
|
||||
*/
|
||||
export function getDownloadUrl(filePath: string, fileName?: string): string {
|
||||
const base = getBaseUrlValue()
|
||||
const params = new URLSearchParams({ path: filePath })
|
||||
if (fileName) params.set('name', fileName)
|
||||
const token = getApiKey()
|
||||
if (token) params.set('token', token)
|
||||
return `${base}/api/hermes/download?${params.toString()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file. Uses fetch to detect errors, then creates a blob URL
|
||||
* for the browser download. Throws with error message on failure.
|
||||
*/
|
||||
export async function downloadFile(filePath: string, fileName?: string): Promise<void> {
|
||||
const url = getDownloadUrl(filePath, fileName)
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
|
||||
throw new Error(body.error || `Download failed: ${res.status}`)
|
||||
}
|
||||
const blob = await res.blob()
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = blobUrl
|
||||
a.download = fileName || filePath.split('/').pop() || 'download'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { request, getApiKey, getBaseUrlValue } from '../client'
|
||||
|
||||
export interface FileEntry {
|
||||
name: string
|
||||
path: string
|
||||
isDir: boolean
|
||||
size: number
|
||||
modTime: string
|
||||
}
|
||||
|
||||
export interface FileStat {
|
||||
name: string
|
||||
path: string
|
||||
isDir: boolean
|
||||
size: number
|
||||
modTime: string
|
||||
permissions?: string
|
||||
}
|
||||
|
||||
export async function listFiles(path: string = ''): Promise<{ entries: FileEntry[]; path: string }> {
|
||||
const params = new URLSearchParams()
|
||||
if (path) params.set('path', path)
|
||||
const query = params.toString()
|
||||
return request<{ entries: FileEntry[]; path: string }>(`/api/hermes/files/list${query ? `?${query}` : ''}`)
|
||||
}
|
||||
|
||||
export async function statFile(path: string): Promise<FileStat> {
|
||||
return request<FileStat>(`/api/hermes/files/stat?path=${encodeURIComponent(path)}`)
|
||||
}
|
||||
|
||||
export async function readFile(path: string): Promise<{ content: string; path: string; size: number }> {
|
||||
return request<{ content: string; path: string; size: number }>(`/api/hermes/files/read?path=${encodeURIComponent(path)}`)
|
||||
}
|
||||
|
||||
export async function writeFile(path: string, content: string): Promise<void> {
|
||||
await request<{ ok: boolean }>('/api/hermes/files/write', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ path, content }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteFile(path: string, recursive: boolean = false): Promise<void> {
|
||||
await request<{ ok: boolean }>('/api/hermes/files/delete', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ path, recursive }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function renameFile(oldPath: string, newPath: string): Promise<void> {
|
||||
await request<{ ok: boolean }>('/api/hermes/files/rename', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ oldPath, newPath }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function mkDir(path: string): Promise<void> {
|
||||
await request<{ ok: boolean }>('/api/hermes/files/mkdir', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function copyFile(srcPath: string, destPath: string): Promise<void> {
|
||||
await request<{ ok: boolean }>('/api/hermes/files/copy', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ srcPath, destPath }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function uploadFiles(targetDir: string, files: File[]): Promise<{ name: string; path: string }[]> {
|
||||
const base = getBaseUrlValue()
|
||||
const formData = new FormData()
|
||||
for (const file of files) {
|
||||
formData.append('file', file)
|
||||
}
|
||||
const params = new URLSearchParams()
|
||||
if (targetDir) params.set('path', targetDir)
|
||||
const query = params.toString()
|
||||
const url = `${base}/api/hermes/files/upload${query ? `?${query}` : ''}`
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
const token = getApiKey()
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||
|
||||
const res = await fetch(url, { method: 'POST', headers, body: formData })
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
|
||||
throw new Error(body.error || `Upload failed: ${res.status}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
return data.files
|
||||
}
|
||||
|
||||
export function getFileDownloadUrl(relativePath: string, fileName?: string): string {
|
||||
const base = getBaseUrlValue()
|
||||
const params = new URLSearchParams({ path: relativePath })
|
||||
if (fileName) params.set('name', fileName)
|
||||
const token = getApiKey()
|
||||
if (token) params.set('token', token)
|
||||
return `${base}/api/hermes/download?${params.toString()}`
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { NButton, NDropdown, NInput, NModal, NTooltip, useMessage } from 'naive-
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { getSourceLabel } from '@/shared/session-display'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
import ChatInput from './ChatInput.vue'
|
||||
import ConversationMonitorPane from './ConversationMonitorPane.vue'
|
||||
import MessageList from './MessageList.vue'
|
||||
@@ -177,11 +178,12 @@ function handleNewChat() {
|
||||
chatStore.newChat()
|
||||
}
|
||||
|
||||
function copySessionId(id?: string) {
|
||||
async function copySessionId(id?: string) {
|
||||
const sessionId = id || chatStore.activeSessionId
|
||||
if (sessionId) {
|
||||
navigator.clipboard.writeText(sessionId)
|
||||
message.success(t('common.copied'))
|
||||
const ok = await copyToClipboard(sessionId)
|
||||
if (ok) message.success(t('common.copied'))
|
||||
else message.error(t('common.copied') + ' ✗')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { handleCodeBlockCopyClick, renderHighlightedCodeBlock } from './highlight'
|
||||
import { downloadFile } from '@/api/hermes/download'
|
||||
|
||||
const props = defineProps<{ content: string }>()
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
|
||||
const md: MarkdownIt = new MarkdownIt({
|
||||
html: false,
|
||||
@@ -20,6 +23,33 @@ const renderedHtml = computed(() => md.render(props.content))
|
||||
|
||||
function handleMarkdownClick(event: MouseEvent): void {
|
||||
void handleCodeBlockCopyClick(event)
|
||||
|
||||
// Handle file path link clicks for download
|
||||
const target = event.target as HTMLElement
|
||||
const link = target.closest('a') as HTMLAnchorElement | null
|
||||
if (!link) return
|
||||
|
||||
const href = link.getAttribute('href')
|
||||
if (!href) return
|
||||
|
||||
// Let http(s) links behave normally
|
||||
if (href.startsWith('http://') || href.startsWith('https://')) {
|
||||
link.target = '_blank'
|
||||
link.rel = 'noopener noreferrer'
|
||||
return
|
||||
}
|
||||
|
||||
// File path links: intercept and download
|
||||
if (href.startsWith('/')) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const linkText = link.textContent || ''
|
||||
const fileName = linkText.startsWith('File: ') ? linkText.slice(6).trim() : linkText.trim()
|
||||
message.info(t('download.downloading'))
|
||||
downloadFile(href, fileName || undefined).catch((err: Error) => {
|
||||
message.error(err.message || t('download.downloadFailed'))
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import type { Message } from "@/stores/hermes/chat";
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useMessage } from "naive-ui";
|
||||
import { downloadFile } from "@/api/hermes/download";
|
||||
import MarkdownRenderer from "./MarkdownRenderer.vue";
|
||||
import {
|
||||
copyTextToClipboard,
|
||||
@@ -13,6 +15,7 @@ const TOOL_PAYLOAD_DISPLAY_LIMIT = 2000;
|
||||
|
||||
const props = defineProps<{ message: Message; highlight?: boolean }>();
|
||||
const { t } = useI18n();
|
||||
const toast = useMessage();
|
||||
|
||||
const isSystem = computed(() => props.message.role === "system");
|
||||
const toolExpanded = ref(false);
|
||||
@@ -32,6 +35,39 @@ function formatSize(bytes: number): string {
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the upload file path from message content for a given attachment.
|
||||
* Upload format in content: [File: name.txt](/tmp/hermes-uploads/abc123.txt)
|
||||
*/
|
||||
function getFilePathFromContent(attName: string): string | null {
|
||||
const content = props.message.content || "";
|
||||
const regex = /\[File:\s*([^\]]+)\]\(([^)]+)\)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
if (match[1].trim() === attName.trim()) return match[2];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function handleAttachmentDownload(att: { name: string; url: string; type: string }) {
|
||||
const filePath = getFilePathFromContent(att.name);
|
||||
if (filePath) {
|
||||
toast.info(t("download.downloading"));
|
||||
downloadFile(filePath, att.name).catch((err: Error) => {
|
||||
toast.error(err.message || t("download.downloadFailed"));
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (att.url && att.url.startsWith("blob:")) {
|
||||
const a = document.createElement("a");
|
||||
a.href = att.url;
|
||||
a.download = att.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
}
|
||||
|
||||
type ToolPayload = {
|
||||
full: string;
|
||||
display: string;
|
||||
@@ -214,7 +250,7 @@ const renderedToolResult = computed(() => {
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="msg-attachment-file">
|
||||
<div class="msg-attachment-file" @click="handleAttachmentDownload(att)" style="cursor: pointer;" :title="t('download.downloadFile')">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
@@ -230,6 +266,11 @@ const renderedToolResult = computed(() => {
|
||||
</svg>
|
||||
<span class="att-name">{{ att.name }}</span>
|
||||
<span class="att-size">{{ formatSize(att.size) }}</span>
|
||||
<svg class="att-download-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NBreadcrumb, NBreadcrumbItem } from 'naive-ui'
|
||||
import { useFilesStore } from '@/stores/hermes/files'
|
||||
|
||||
const { t } = useI18n()
|
||||
const filesStore = useFilesStore()
|
||||
|
||||
function handleClick(index: number) {
|
||||
if (index < 0) {
|
||||
filesStore.navigateTo('')
|
||||
} else {
|
||||
const path = filesStore.pathSegments.slice(0, index + 1).join('/')
|
||||
filesStore.navigateTo(path)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="file-breadcrumb">
|
||||
<NBreadcrumb>
|
||||
<NBreadcrumbItem @click="handleClick(-1)">
|
||||
{{ t('files.breadcrumbRoot') }}
|
||||
</NBreadcrumbItem>
|
||||
<NBreadcrumbItem
|
||||
v-for="(segment, index) in filesStore.pathSegments"
|
||||
:key="index"
|
||||
@click="handleClick(index)"
|
||||
>
|
||||
{{ segment }}
|
||||
</NBreadcrumbItem>
|
||||
</NBreadcrumb>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.file-breadcrumb {
|
||||
padding: 0 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { NDropdown, useMessage, useDialog } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFilesStore, isTextFile, isImageFile, isMarkdownFile } from '@/stores/hermes/files'
|
||||
import { downloadFile } from '@/api/hermes/download'
|
||||
import type { FileEntry } from '@/api/hermes/files'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const filesStore = useFilesStore()
|
||||
|
||||
const showMenu = ref(false)
|
||||
const menuX = ref(0)
|
||||
const menuY = ref(0)
|
||||
const targetEntry = ref<FileEntry | null>(null)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'rename', entry: FileEntry): void
|
||||
}>()
|
||||
|
||||
function show(e: MouseEvent, entry: FileEntry) {
|
||||
targetEntry.value = entry
|
||||
menuX.value = e.clientX
|
||||
menuY.value = e.clientY
|
||||
showMenu.value = false
|
||||
nextTick(() => {
|
||||
showMenu.value = true
|
||||
})
|
||||
}
|
||||
|
||||
function getOptions() {
|
||||
const entry = targetEntry.value
|
||||
if (!entry) return []
|
||||
const options: any[] = []
|
||||
|
||||
if (entry.isDir) {
|
||||
options.push({ label: t('files.open'), key: 'open' })
|
||||
} else {
|
||||
if (isTextFile(entry.name)) {
|
||||
options.push({ label: t('files.edit'), key: 'edit' })
|
||||
}
|
||||
if (isImageFile(entry.name) || isMarkdownFile(entry.name)) {
|
||||
options.push({ label: t('files.preview'), key: 'preview' })
|
||||
}
|
||||
options.push({ label: t('files.download'), key: 'download' })
|
||||
}
|
||||
options.push({ type: 'divider', key: 'd1' })
|
||||
options.push({ label: t('files.copyPath'), key: 'copyPath' })
|
||||
options.push({ label: t('files.rename'), key: 'rename' })
|
||||
options.push({ type: 'divider', key: 'd2' })
|
||||
options.push({ label: t('files.delete'), key: 'delete' })
|
||||
return options
|
||||
}
|
||||
|
||||
async function handleSelect(key: string) {
|
||||
showMenu.value = false
|
||||
const entry = targetEntry.value
|
||||
if (!entry) return
|
||||
|
||||
switch (key) {
|
||||
case 'open':
|
||||
filesStore.navigateTo(entry.path)
|
||||
break
|
||||
case 'edit':
|
||||
try { await filesStore.openEditor(entry.path) } catch { message.error(t('files.backendError')) }
|
||||
break
|
||||
case 'preview':
|
||||
try { await filesStore.openPreview(entry) } catch { message.error(t('files.backendError')) }
|
||||
break
|
||||
case 'download':
|
||||
try { await downloadFile(entry.path, entry.name) } catch (err: any) { message.error(err.message) }
|
||||
break
|
||||
case 'copyPath': {
|
||||
const ok = await copyToClipboard(entry.path)
|
||||
if (ok) {
|
||||
message.success(t('files.pathCopied'))
|
||||
} else {
|
||||
message.error(t('files.pathCopied') + ' ✗')
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'rename':
|
||||
emit('rename', entry)
|
||||
break
|
||||
case 'delete':
|
||||
dialog.warning({
|
||||
title: t('files.delete'),
|
||||
content: entry.isDir ? t('files.confirmDeleteDir', { name: entry.name }) : t('files.confirmDelete', { name: entry.name }),
|
||||
positiveText: t('common.delete'),
|
||||
negativeText: t('common.cancel'),
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await filesStore.deleteEntry(entry)
|
||||
message.success(t('files.deleted'))
|
||||
} catch {
|
||||
message.error(t('files.deleteFailed'))
|
||||
}
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside() {
|
||||
showMenu.value = false
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDropdown
|
||||
:show="showMenu"
|
||||
:x="menuX"
|
||||
:y="menuY"
|
||||
:options="getOptions()"
|
||||
placement="bottom-start"
|
||||
trigger="manual"
|
||||
@select="handleSelect"
|
||||
@clickoutside="handleClickOutside"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { NButton, NSpace, useMessage, useDialog } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFilesStore } from '@/stores/hermes/files'
|
||||
import * as monaco from 'monaco-editor'
|
||||
|
||||
// Configure Monaco workers using import.meta.url
|
||||
;(self as any).MonacoEnvironment = {
|
||||
getWorker(_: any, _label: string) {
|
||||
return new Worker(
|
||||
new URL('monaco-editor/esm/vs/editor/editor.worker.js', import.meta.url),
|
||||
{ type: 'module' }
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
const dialogApi = useDialog()
|
||||
const filesStore = useFilesStore()
|
||||
|
||||
const editorContainer = ref<HTMLElement | null>(null)
|
||||
let editor: monaco.editor.IStandaloneCodeEditor | null = null
|
||||
const saving = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
if (!editorContainer.value || !filesStore.editingFile) return
|
||||
|
||||
editor = monaco.editor.create(editorContainer.value, {
|
||||
value: filesStore.editingFile.content,
|
||||
language: filesStore.editingFile.language,
|
||||
theme: document.documentElement.classList.contains('dark') ? 'vs-dark' : 'vs',
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
lineNumbers: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
tabSize: 2,
|
||||
wordWrap: 'on',
|
||||
})
|
||||
|
||||
editor.onDidChangeModelContent(() => {
|
||||
if (filesStore.editingFile) {
|
||||
filesStore.editingFile.content = editor!.getValue()
|
||||
}
|
||||
})
|
||||
|
||||
// Ctrl/Cmd + S to save
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
||||
handleSave()
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
editor?.dispose()
|
||||
editor = null
|
||||
})
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
await filesStore.saveEditor()
|
||||
message.success(t('files.saved'))
|
||||
} catch {
|
||||
message.error(t('files.saveFailed'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
if (filesStore.hasUnsavedChanges) {
|
||||
dialogApi.warning({
|
||||
title: t('files.unsavedChanges'),
|
||||
positiveText: t('common.ok'),
|
||||
negativeText: t('common.cancel'),
|
||||
onPositiveClick: () => {
|
||||
filesStore.closeEditor()
|
||||
},
|
||||
})
|
||||
} else {
|
||||
filesStore.closeEditor()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="file-editor">
|
||||
<div class="editor-header">
|
||||
<span class="editor-filename">{{ filesStore.editingFile?.path }}</span>
|
||||
<NSpace>
|
||||
<NButton size="small" type="primary" :loading="saving" @click="handleSave">
|
||||
{{ t('files.saveFile') }}
|
||||
</NButton>
|
||||
<NButton size="small" @click="handleClose">
|
||||
{{ t('files.closeEditor') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</div>
|
||||
<div ref="editorContainer" class="editor-container" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.file-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
background-color: $bg-card;
|
||||
}
|
||||
|
||||
.editor-filename {
|
||||
font-size: 13px;
|
||||
color: $text-secondary;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,212 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NSpin, NEmpty, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFilesStore, isImageFile, isMarkdownFile, isTextFile } from '@/stores/hermes/files'
|
||||
import { downloadFile } from '@/api/hermes/download'
|
||||
import type { FileEntry } from '@/api/hermes/files'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
const filesStore = useFilesStore()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'contextmenu-entry', event: MouseEvent, entry: FileEntry): void
|
||||
}>()
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return '—'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let i = 0
|
||||
let size = bytes
|
||||
while (size >= 1024 && i < units.length - 1) {
|
||||
size /= 1024
|
||||
i++
|
||||
}
|
||||
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
if (!iso) return '—'
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString()
|
||||
}
|
||||
|
||||
function getFileIcon(entry: FileEntry): string {
|
||||
if (entry.isDir) return '📁'
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || ''
|
||||
const iconMap: Record<string, string> = {
|
||||
yaml: '⚙️', yml: '⚙️', json: '📋', toml: '⚙️',
|
||||
md: '📝', txt: '📄', log: '📄',
|
||||
py: '🐍', js: '📜', ts: '📜', vue: '💚',
|
||||
png: '🖼️', jpg: '🖼️', jpeg: '🖼️', gif: '🖼️', svg: '🖼️', webp: '🖼️',
|
||||
zip: '📦', gz: '📦', tar: '📦',
|
||||
sh: '⚡', bash: '⚡',
|
||||
}
|
||||
return iconMap[ext] || '📄'
|
||||
}
|
||||
|
||||
function handleDoubleClick(entry: FileEntry) {
|
||||
if (entry.isDir) {
|
||||
filesStore.navigateTo(entry.path)
|
||||
} else if (isTextFile(entry.name)) {
|
||||
filesStore.openEditor(entry.path)
|
||||
} else if (isImageFile(entry.name) || isMarkdownFile(entry.name)) {
|
||||
filesStore.openPreview(entry)
|
||||
}
|
||||
}
|
||||
|
||||
function handleContextMenu(e: MouseEvent, entry: FileEntry) {
|
||||
e.preventDefault()
|
||||
emit('contextmenu-entry', e, entry)
|
||||
}
|
||||
|
||||
async function handleDownload(entry: FileEntry) {
|
||||
try {
|
||||
await downloadFile(entry.path, entry.name)
|
||||
} catch (err: any) {
|
||||
message.error(err.message || t('files.backendError'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="file-list">
|
||||
<NSpin :show="filesStore.loading">
|
||||
<NEmpty v-if="!filesStore.loading && filesStore.sortedEntries.length === 0" :description="t('files.emptyDir')" />
|
||||
<div v-else class="file-list-items">
|
||||
<div class="file-list-header">
|
||||
<div class="file-name sort-header" @click="filesStore.setSort('name')">
|
||||
{{ t('files.name') }}
|
||||
<span v-if="filesStore.sortBy === 'name'" class="sort-indicator">{{ filesStore.sortOrder === 'asc' ? '↑' : '↓' }}</span>
|
||||
</div>
|
||||
<div class="file-size sort-header" @click="filesStore.setSort('size')">
|
||||
{{ t('files.size') }}
|
||||
<span v-if="filesStore.sortBy === 'size'" class="sort-indicator">{{ filesStore.sortOrder === 'asc' ? '↑' : '↓' }}</span>
|
||||
</div>
|
||||
<div class="file-date sort-header" @click="filesStore.setSort('modTime')">
|
||||
{{ t('files.modified') }}
|
||||
<span v-if="filesStore.sortBy === 'modTime'" class="sort-indicator">{{ filesStore.sortOrder === 'asc' ? '↑' : '↓' }}</span>
|
||||
</div>
|
||||
<div class="file-actions-placeholder" />
|
||||
</div>
|
||||
<div
|
||||
v-for="entry in filesStore.sortedEntries"
|
||||
:key="entry.path"
|
||||
class="file-list-row"
|
||||
@dblclick="handleDoubleClick(entry)"
|
||||
@contextmenu="handleContextMenu($event, entry)"
|
||||
>
|
||||
<div class="file-name">
|
||||
<span class="file-icon">{{ getFileIcon(entry) }}</span>
|
||||
<span>{{ entry.name }}</span>
|
||||
</div>
|
||||
<div class="file-size">{{ entry.isDir ? '—' : formatSize(entry.size) }}</div>
|
||||
<div class="file-date">{{ formatDate(entry.modTime) }}</div>
|
||||
<div class="file-actions">
|
||||
<NButton v-if="isTextFile(entry.name) && !entry.isDir" size="tiny" quaternary @click.stop="filesStore.openEditor(entry.path)" :title="t('files.edit')">✏️</NButton>
|
||||
<NButton v-if="!entry.isDir" size="tiny" quaternary @click.stop="handleDownload(entry)" :title="t('files.download')">⬇️</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NSpin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.file-list {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.file-list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
gap: 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: $text-muted;
|
||||
border-bottom: 1px solid $border-light;
|
||||
margin-bottom: 4px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sort-header {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: $text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.sort-indicator {
|
||||
margin-left: 2px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.file-actions-placeholder {
|
||||
width: 60px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: $radius-sm;
|
||||
cursor: pointer;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(var(--accent-primary-rgb), 0.06);
|
||||
|
||||
.file-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.file-name {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
width: 80px;
|
||||
text-align: right;
|
||||
color: $text-secondary;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-date {
|
||||
width: 160px;
|
||||
color: $text-secondary;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
opacity: 0;
|
||||
transition: opacity $transition-fast;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
.file-size, .file-date {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFilesStore } from '@/stores/hermes/files'
|
||||
import { getFileDownloadUrl } from '@/api/hermes/files'
|
||||
import MarkdownRenderer from '@/components/hermes/chat/MarkdownRenderer.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const filesStore = useFilesStore()
|
||||
|
||||
function getImageUrl(): string {
|
||||
if (!filesStore.previewFile) return ''
|
||||
return getFileDownloadUrl(filesStore.previewFile.path)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="file-preview" v-if="filesStore.previewFile">
|
||||
<div class="preview-header">
|
||||
<span class="preview-filename">{{ filesStore.previewFile.path }}</span>
|
||||
<NButton size="small" @click="filesStore.closePreview()">
|
||||
{{ t('common.cancel') }}
|
||||
</NButton>
|
||||
</div>
|
||||
<div class="preview-content">
|
||||
<img
|
||||
v-if="filesStore.previewFile.type === 'image'"
|
||||
:src="getImageUrl()"
|
||||
class="preview-image"
|
||||
:alt="filesStore.previewFile.path"
|
||||
/>
|
||||
<div v-else-if="filesStore.previewFile.type === 'markdown'" class="preview-markdown">
|
||||
<MarkdownRenderer :content="filesStore.previewFile.content || ''" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.file-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.preview-filename {
|
||||
font-size: 13px;
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.preview-markdown {
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { NModal, NInput, NButton, NSpace, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFilesStore } from '@/stores/hermes/files'
|
||||
import type { FileEntry } from '@/api/hermes/files'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
const filesStore = useFilesStore()
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
mode: 'newFile' | 'newFolder' | 'rename'
|
||||
entry?: FileEntry | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:show', value: boolean): void
|
||||
}>()
|
||||
|
||||
const inputValue = ref('')
|
||||
const submitting = ref(false)
|
||||
|
||||
watch(() => props.show, (val) => {
|
||||
if (val) {
|
||||
if (props.mode === 'rename' && props.entry) {
|
||||
inputValue.value = props.entry.name
|
||||
} else {
|
||||
inputValue.value = ''
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const title = computed(() => {
|
||||
switch (props.mode) {
|
||||
case 'newFile': return t('files.newFile')
|
||||
case 'newFolder': return t('files.newFolder')
|
||||
case 'rename': return t('files.rename')
|
||||
}
|
||||
})
|
||||
|
||||
const placeholder = computed(() => {
|
||||
switch (props.mode) {
|
||||
case 'newFile': return t('files.newFileName')
|
||||
case 'newFolder': return t('files.newFolderName')
|
||||
case 'rename': return t('files.renameTo')
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!inputValue.value.trim()) return
|
||||
submitting.value = true
|
||||
try {
|
||||
switch (props.mode) {
|
||||
case 'newFile':
|
||||
await filesStore.createFile(inputValue.value.trim())
|
||||
message.success(t('files.created'))
|
||||
break
|
||||
case 'newFolder':
|
||||
await filesStore.createDir(inputValue.value.trim())
|
||||
message.success(t('files.created'))
|
||||
break
|
||||
case 'rename':
|
||||
if (props.entry) {
|
||||
await filesStore.renameEntry(props.entry, inputValue.value.trim())
|
||||
message.success(t('files.renamed'))
|
||||
}
|
||||
break
|
||||
}
|
||||
emit('update:show', false)
|
||||
} catch (err: any) {
|
||||
const msg = props.mode === 'rename' ? t('files.renameFailed') : t('files.createFailed')
|
||||
message.error(err.message || msg)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal :show="props.show" preset="dialog" :title="title" @update:show="emit('update:show', false)" style="width: 400px;">
|
||||
<NInput
|
||||
v-model:value="inputValue"
|
||||
:placeholder="placeholder"
|
||||
@keydown.enter="handleSubmit"
|
||||
autofocus
|
||||
/>
|
||||
<template #action>
|
||||
<NSpace>
|
||||
<NButton @click="emit('update:show', false)">{{ t('common.cancel') }}</NButton>
|
||||
<NButton type="primary" :loading="submitting" :disabled="!inputValue.trim()" @click="handleSubmit">
|
||||
{{ t('common.ok') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NModal>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NSpace, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFilesStore } from '@/stores/hermes/files'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
const filesStore = useFilesStore()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'showNewFile'): void
|
||||
(e: 'showNewFolder'): void
|
||||
(e: 'showUpload'): void
|
||||
}>()
|
||||
|
||||
async function handleRefresh() {
|
||||
try {
|
||||
await filesStore.fetchEntries()
|
||||
} catch {
|
||||
message.error(t('files.backendError'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="file-toolbar">
|
||||
<NSpace>
|
||||
<NButton size="small" @click="emit('showNewFile')">
|
||||
{{ t('files.newFile') }}
|
||||
</NButton>
|
||||
<NButton size="small" @click="emit('showNewFolder')">
|
||||
{{ t('files.newFolder') }}
|
||||
</NButton>
|
||||
<NButton size="small" @click="emit('showUpload')">
|
||||
{{ t('files.upload') }}
|
||||
</NButton>
|
||||
<NButton size="small" @click="handleRefresh">
|
||||
{{ t('files.refresh') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.file-toolbar {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { NTree } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFilesStore } from '@/stores/hermes/files'
|
||||
import * as filesApi from '@/api/hermes/files'
|
||||
import type { TreeOption } from 'naive-ui'
|
||||
|
||||
const { t } = useI18n()
|
||||
const filesStore = useFilesStore()
|
||||
|
||||
const treeData = ref<TreeOption[]>([])
|
||||
const selectedKeys = ref<string[]>([])
|
||||
|
||||
async function loadChildren(path: string): Promise<TreeOption[]> {
|
||||
try {
|
||||
const result = await filesApi.listFiles(path)
|
||||
return result.entries
|
||||
.filter(e => e.isDir)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map(e => ({
|
||||
key: e.path,
|
||||
label: e.name,
|
||||
isLeaf: false,
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLoad(node: TreeOption): Promise<void> {
|
||||
node.children = await loadChildren(node.key as string)
|
||||
}
|
||||
|
||||
function handleSelect(keys: string[]) {
|
||||
if (keys.length > 0) {
|
||||
selectedKeys.value = keys
|
||||
filesStore.navigateTo(keys[0])
|
||||
}
|
||||
}
|
||||
|
||||
function handleRootClick() {
|
||||
selectedKeys.value = []
|
||||
filesStore.navigateTo('')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
treeData.value = await loadChildren('')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="file-tree">
|
||||
<div class="tree-header" @click="handleRootClick">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
<polyline points="9 22 9 12 15 12 15 22" />
|
||||
</svg>
|
||||
<span>{{ t('files.breadcrumbRoot') }}</span>
|
||||
</div>
|
||||
<NTree
|
||||
:data="treeData"
|
||||
:selected-keys="selectedKeys"
|
||||
:on-load="handleLoad"
|
||||
expand-on-click
|
||||
block-line
|
||||
@update:selected-keys="handleSelect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.file-tree {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.tree-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border-radius: $radius-sm;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: $text-primary;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(var(--accent-primary-rgb), 0.06);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { NModal, NButton, NUpload, NSpace, useMessage } from 'naive-ui'
|
||||
import type { UploadFileInfo } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFilesStore } from '@/stores/hermes/files'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
const filesStore = useFilesStore()
|
||||
|
||||
const props = defineProps<{ show: boolean }>()
|
||||
const emit = defineEmits<{ (e: 'update:show', value: boolean): void }>()
|
||||
|
||||
const uploading = ref(false)
|
||||
const fileList = ref<File[]>([])
|
||||
|
||||
function handleFileChange(data: { file: UploadFileInfo; fileList: UploadFileInfo[] }) {
|
||||
fileList.value = data.fileList
|
||||
.map((f: UploadFileInfo) => f.file)
|
||||
.filter((f): f is File => f != null)
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
if (fileList.value.length === 0) return
|
||||
uploading.value = true
|
||||
try {
|
||||
await filesStore.uploadFiles(fileList.value)
|
||||
message.success(t('files.uploadSuccess', { count: fileList.value.length }))
|
||||
fileList.value = []
|
||||
emit('update:show', false)
|
||||
} catch (err: any) {
|
||||
message.error(err.message || t('files.uploadFailed'))
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
fileList.value = []
|
||||
emit('update:show', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal :show="props.show" preset="dialog" :title="t('files.upload')" @update:show="handleClose" style="width: 500px;">
|
||||
<NUpload
|
||||
multiple
|
||||
directory-dnd
|
||||
:default-upload="false"
|
||||
@change="handleFileChange"
|
||||
>
|
||||
<div class="upload-dragger">
|
||||
<p>{{ t('files.dragDropHint') }}</p>
|
||||
</div>
|
||||
</NUpload>
|
||||
<template #action>
|
||||
<NSpace>
|
||||
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
|
||||
<NButton type="primary" :loading="uploading" :disabled="fileList.length === 0" @click="handleUpload">
|
||||
{{ t('files.upload') }} ({{ fileList.length }})
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.upload-dragger {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload-dragger p {
|
||||
margin-top: 12px;
|
||||
opacity: 0.6;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@ import { ref, onUnmounted } from 'vue'
|
||||
import { NModal, NButton, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { startCodexLogin, pollCodexLogin } from '@/api/hermes/codex-auth'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
|
||||
const { t } = useI18n()
|
||||
const emit = defineEmits<{ close: []; success: [] }>()
|
||||
@@ -85,9 +86,10 @@ function handleClose() {
|
||||
setTimeout(() => emit('close'), 200)
|
||||
}
|
||||
|
||||
function copyCode() {
|
||||
navigator.clipboard.writeText(userCode.value)
|
||||
message.success(t('models.codexCopyCode'))
|
||||
async function copyCode() {
|
||||
const ok = await copyToClipboard(userCode.value)
|
||||
if (ok) message.success(t('models.codexCopyCode'))
|
||||
else message.error(t('models.codexCopyCode') + ' ✗')
|
||||
}
|
||||
|
||||
function openLink() {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, onUnmounted } from 'vue'
|
||||
import { NModal, NButton, NSpin, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { startNousLogin, pollNousLogin } from '@/api/hermes/nous-auth'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
|
||||
const { t } = useI18n()
|
||||
const emit = defineEmits<{ close: []; success: [] }>()
|
||||
@@ -87,9 +88,10 @@ function handleClose() {
|
||||
setTimeout(() => emit('close'), 200)
|
||||
}
|
||||
|
||||
function copyCode() {
|
||||
navigator.clipboard.writeText(userCode.value)
|
||||
message.success(t('models.nousCopyCode'))
|
||||
async function copyCode() {
|
||||
const ok = await copyToClipboard(userCode.value)
|
||||
if (ok) message.success(t('models.nousCopyCode'))
|
||||
else message.error(t('models.nousCopyCode') + ' ✗')
|
||||
}
|
||||
|
||||
function openLink() {
|
||||
|
||||
@@ -190,6 +190,12 @@ function openChangelog() {
|
||||
</svg>
|
||||
<span>{{ t("sidebar.terminal") }}</span>
|
||||
</button>
|
||||
<button class="nav-item" :class="{ active: selectedKey === 'hermes.files' }" @click="handleNav('hermes.files')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<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>{{ t("sidebar.files") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ export default {
|
||||
channels: 'Channels',
|
||||
gateways: 'Gateways',
|
||||
terminal: 'Terminal',
|
||||
files: 'Files',
|
||||
groupConversation: 'Conversation',
|
||||
groupPlatform: 'Platform',
|
||||
groupAgent: 'Agent',
|
||||
@@ -548,6 +549,66 @@ export default {
|
||||
cost: 'Cost',
|
||||
noData: 'No usage data',
|
||||
},
|
||||
// Files
|
||||
files: {
|
||||
title: 'Files',
|
||||
tree: 'Directory Tree',
|
||||
list: 'File List',
|
||||
breadcrumbRoot: 'Home',
|
||||
newFile: 'New File',
|
||||
newFolder: 'New Folder',
|
||||
upload: 'Upload',
|
||||
refresh: 'Refresh',
|
||||
open: 'Open',
|
||||
edit: 'Edit',
|
||||
preview: 'Preview',
|
||||
download: 'Download',
|
||||
copyPath: 'Copy Path',
|
||||
rename: 'Rename',
|
||||
delete: 'Delete',
|
||||
name: 'Name',
|
||||
size: 'Size',
|
||||
modified: 'Modified',
|
||||
actions: 'Actions',
|
||||
emptyDir: 'Empty directory',
|
||||
loading: 'Loading...',
|
||||
confirmDelete: 'Are you sure you want to delete "{name}"?',
|
||||
confirmDeleteDir: 'Are you sure you want to delete directory "{name}" and all its contents?',
|
||||
deleteFailed: 'Delete failed',
|
||||
deleted: 'Deleted',
|
||||
renameTo: 'Rename to',
|
||||
newFileName: 'File name',
|
||||
newFolderName: 'Folder name',
|
||||
created: 'Created',
|
||||
createFailed: 'Create failed',
|
||||
renamed: 'Renamed',
|
||||
renameFailed: 'Rename failed',
|
||||
uploadSuccess: 'Uploaded {count} file(s)',
|
||||
uploadFailed: 'Upload failed',
|
||||
saveFailed: 'Save failed',
|
||||
saved: 'Saved',
|
||||
unsavedChanges: 'You have unsaved changes. Discard?',
|
||||
pathCopied: 'Path copied',
|
||||
fileTooLarge: 'File too large (max 10MB)',
|
||||
permissionDenied: 'Cannot modify protected file',
|
||||
notFound: 'File or directory not found',
|
||||
backendError: 'File operation failed',
|
||||
dragDropHint: 'Drag files here to upload',
|
||||
closeEditor: 'Close Editor',
|
||||
saveFile: 'Save',
|
||||
},
|
||||
// Download
|
||||
download: {
|
||||
downloading: 'Downloading...',
|
||||
downloadFailed: 'Download failed',
|
||||
fileNotFound: 'File not found or deleted',
|
||||
fileTooLarge: 'File too large (exceeds limit)',
|
||||
backendError: 'File read failed, remote environment may be unavailable',
|
||||
backendTimeout: 'File read timed out',
|
||||
unsupportedBackend: 'Current terminal backend does not support file download',
|
||||
invalidPath: 'Invalid file path',
|
||||
download: 'Download',
|
||||
},
|
||||
|
||||
// Changelog
|
||||
changelog: {
|
||||
|
||||
@@ -79,6 +79,7 @@ export default {
|
||||
channels: '频道',
|
||||
gateways: '网关',
|
||||
terminal: '终端',
|
||||
files: '文件',
|
||||
groupConversation: '对话',
|
||||
groupPlatform: '平台',
|
||||
groupAgent: '代理',
|
||||
@@ -550,6 +551,66 @@ export default {
|
||||
cost: '费用',
|
||||
noData: '暂无用量数据',
|
||||
},
|
||||
// 文件管理
|
||||
files: {
|
||||
title: '文件',
|
||||
tree: '目录树',
|
||||
list: '文件列表',
|
||||
breadcrumbRoot: '根目录',
|
||||
newFile: '新建文件',
|
||||
newFolder: '新建文件夹',
|
||||
upload: '上传',
|
||||
refresh: '刷新',
|
||||
open: '打开',
|
||||
edit: '编辑',
|
||||
preview: '预览',
|
||||
download: '下载',
|
||||
copyPath: '复制路径',
|
||||
rename: '重命名',
|
||||
delete: '删除',
|
||||
name: '名称',
|
||||
size: '大小',
|
||||
modified: '修改时间',
|
||||
actions: '操作',
|
||||
emptyDir: '空目录',
|
||||
loading: '加载中...',
|
||||
confirmDelete: '确定要删除「{name}」吗?',
|
||||
confirmDeleteDir: '确定要删除目录「{name}」及其所有内容吗?',
|
||||
deleteFailed: '删除失败',
|
||||
deleted: '已删除',
|
||||
renameTo: '重命名为',
|
||||
newFileName: '文件名',
|
||||
newFolderName: '文件夹名',
|
||||
created: '已创建',
|
||||
createFailed: '创建失败',
|
||||
renamed: '已重命名',
|
||||
renameFailed: '重命名失败',
|
||||
uploadSuccess: '已上传 {count} 个文件',
|
||||
uploadFailed: '上传失败',
|
||||
saveFailed: '保存失败',
|
||||
saved: '已保存',
|
||||
unsavedChanges: '有未保存的更改,是否丢弃?',
|
||||
pathCopied: '路径已复制',
|
||||
fileTooLarge: '文件过大(最大 10MB)',
|
||||
permissionDenied: '无法修改受保护的文件',
|
||||
notFound: '文件或目录不存在',
|
||||
backendError: '文件操作失败',
|
||||
dragDropHint: '拖拽文件到此处上传',
|
||||
closeEditor: '关闭编辑器',
|
||||
saveFile: '保存',
|
||||
},
|
||||
// 下载
|
||||
download: {
|
||||
downloading: '正在下载...',
|
||||
downloadFailed: '下载失败',
|
||||
fileNotFound: '文件不存在或已被删除',
|
||||
fileTooLarge: '文件过大(超过限制)',
|
||||
backendError: '文件读取失败,远程环境可能不可用',
|
||||
backendTimeout: '文件读取超时',
|
||||
unsupportedBackend: '当前 terminal backend 暂不支持文件下载',
|
||||
invalidPath: '无效的文件路径',
|
||||
download: '下载',
|
||||
},
|
||||
|
||||
// 更新日志
|
||||
changelog: {
|
||||
|
||||
@@ -70,6 +70,11 @@ const router = createRouter({
|
||||
name: 'hermes.terminal',
|
||||
component: () => import('@/views/hermes/TerminalView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/hermes/files',
|
||||
name: 'hermes.files',
|
||||
component: () => import('@/views/hermes/FilesView.vue'),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Copy text to clipboard with a fallback for non-secure contexts (HTTP, non-localhost).
|
||||
* `navigator.clipboard` is only available in secure contexts; intranet HTTP deployments
|
||||
* must fall back to the legacy `document.execCommand('copy')` flow via a hidden textarea.
|
||||
*/
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
} catch {
|
||||
// fall through to legacy fallback
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof document === 'undefined') return false
|
||||
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = text
|
||||
textarea.setAttribute('readonly', '')
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.top = '0'
|
||||
textarea.style.left = '0'
|
||||
textarea.style.width = '1px'
|
||||
textarea.style.height = '1px'
|
||||
textarea.style.padding = '0'
|
||||
textarea.style.border = 'none'
|
||||
textarea.style.outline = 'none'
|
||||
textarea.style.boxShadow = 'none'
|
||||
textarea.style.background = 'transparent'
|
||||
textarea.style.opacity = '0'
|
||||
document.body.appendChild(textarea)
|
||||
|
||||
let ok = false
|
||||
try {
|
||||
textarea.focus()
|
||||
textarea.select()
|
||||
textarea.setSelectionRange(0, text.length)
|
||||
ok = document.execCommand('copy')
|
||||
} catch {
|
||||
ok = false
|
||||
} finally {
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
return ok
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user