polish file preview drawer
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NDrawer, NSpin, useMessage } from 'naive-ui'
|
||||
import { NDrawer, NDrawerContent, NSpin, useMessage } from 'naive-ui'
|
||||
import type MarkdownIt from 'markdown-it'
|
||||
import MarkdownItConstructor from 'markdown-it'
|
||||
import { handleCodeBlockCopyClick, renderHighlightedCodeBlock } from './highlight'
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from './mermaidRenderer'
|
||||
import { downloadFile, getDownloadUrl, fetchFileText } from '@/api/hermes/download'
|
||||
|
||||
const PREVIEW_AREA_WIDTH = 500
|
||||
const PREVIEW_AREA_WIDTH = 'min(800px, 100vw)'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
content: string
|
||||
@@ -62,9 +62,12 @@ const previewUrl = ref<string | null>(null)
|
||||
|
||||
// Preview config variable
|
||||
const textPreviewContent = ref<string | null>(null)
|
||||
const textPreviewFileName = ref('')
|
||||
const textPreviewLoading = ref(false)
|
||||
const textPreviewVisible = ref(false)
|
||||
|
||||
const textPreviewIsMarkdown = computed(() => /\.(md|markdown)$/i.test(textPreviewFileName.value))
|
||||
|
||||
let renderGeneration = 0
|
||||
let unmounted = false
|
||||
|
||||
@@ -137,11 +140,13 @@ const renderedHtml = computed(() => {
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
<span class="att-name">${fileName}</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>
|
||||
<button class="att-download-btn" type="button" title="${t('download.downloadFile')}" aria-label="${t('download.downloadFile')}">
|
||||
<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>
|
||||
</button>
|
||||
</div>`
|
||||
})
|
||||
|
||||
@@ -316,24 +321,24 @@ async function handleMarkdownClick(event: MouseEvent): Promise<void> {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const path = fileCard.getAttribute('data-path')
|
||||
const fileName = fileCard.getAttribute('data-filename')
|
||||
const fileName = fileCard.getAttribute('data-filename') || undefined
|
||||
|
||||
const isDownloadBtn = target.closest('.att-download-btn')
|
||||
|
||||
if (isDownloadBtn && path) { // Only download file with download icon clicked.
|
||||
message.info(t('download.downloading'))
|
||||
downloadFile(path, fileName || undefined).catch((err: Error) => {
|
||||
downloadFile(path, fileName).catch((err: Error) => {
|
||||
message.error(err.message || t('download.downloadFailed'))
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (path) {
|
||||
const ext = fileName.split('.').pop()?.toLowerCase()
|
||||
const ext = fileName?.split('.').pop()?.toLowerCase()
|
||||
if (SUPPORT_PREVIEW_FILE_TYPES.includes(ext || '')) {
|
||||
previewTextFile(path, fileName)
|
||||
previewTextFile(path, fileName || '')
|
||||
} else { // Download file immediately
|
||||
downloadFile(path, fileName || undefined).catch((err: Error) => {
|
||||
downloadFile(path, fileName).catch((err: Error) => {
|
||||
message.error(err.message || t('download.downloadFailed'))
|
||||
})
|
||||
}
|
||||
@@ -389,6 +394,8 @@ async function handleMarkdownClick(event: MouseEvent): Promise<void> {
|
||||
async function previewTextFile(path: string, fileName: string): Promise<void> {
|
||||
textPreviewLoading.value = true
|
||||
textPreviewVisible.value = true
|
||||
textPreviewFileName.value = fileName
|
||||
textPreviewContent.value = null
|
||||
try {
|
||||
textPreviewContent.value = await fetchFileText(path, fileName)
|
||||
} catch (err: any) {
|
||||
@@ -397,6 +404,10 @@ async function previewTextFile(path: string, fileName: string): Promise<void> {
|
||||
textPreviewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeTextPreview(): void {
|
||||
textPreviewVisible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -408,13 +419,21 @@ async function previewTextFile(path: string, fileName: string): Promise<void> {
|
||||
placement="right"
|
||||
:show-mask="false"
|
||||
:trap-focus="false"
|
||||
class="markdown-text-preview-drawer"
|
||||
>
|
||||
<template #header>
|
||||
📄 {{ t('download.preview') }}
|
||||
</template>
|
||||
<NSpin :show="textPreviewLoading">
|
||||
<pre v-if="textPreviewContent !== null" class="text-preview-body">{{ textPreviewContent }}</pre>
|
||||
</NSpin>
|
||||
<NDrawerContent
|
||||
:title="t('download.contentDisplay')"
|
||||
closable
|
||||
:body-content-style="{ padding: 0 }"
|
||||
@close="closeTextPreview"
|
||||
>
|
||||
<NSpin :show="textPreviewLoading">
|
||||
<div v-if="textPreviewContent !== null && textPreviewIsMarkdown" class="text-preview-markdown">
|
||||
<MarkdownRenderer :content="textPreviewContent" />
|
||||
</div>
|
||||
<pre v-else-if="textPreviewContent !== null" class="text-preview-body">{{ textPreviewContent }}</pre>
|
||||
</NSpin>
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
<Teleport to="body">
|
||||
<div v-if="previewUrl" class="image-preview-overlay" @click.self="previewUrl = null">
|
||||
@@ -545,7 +564,22 @@ async function previewTextFile(path: string, fileName: string): Promise<void> {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
&:hover .att-download-icon {
|
||||
.att-download-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:hover .att-download-icon,
|
||||
.att-download-btn:hover .att-download-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -654,6 +688,40 @@ async function previewTextFile(path: string, fileName: string): Promise<void> {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: $text-primary;
|
||||
background: $code-bg;
|
||||
}
|
||||
|
||||
.text-preview-markdown {
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.markdown-text-preview-drawer {
|
||||
max-width: 100vw;
|
||||
|
||||
.n-drawer-content,
|
||||
.n-drawer-body-content-wrapper {
|
||||
max-width: 100vw;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
.markdown-text-preview-drawer {
|
||||
max-width: 100vw;
|
||||
|
||||
.n-drawer-content,
|
||||
.n-drawer-body-content-wrapper {
|
||||
max-width: 100vw;
|
||||
}
|
||||
}
|
||||
|
||||
.text-preview-body {
|
||||
padding: 12px;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.text-preview-markdown {
|
||||
padding: 12px;
|
||||
max-width: 100vw;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1161,6 +1161,7 @@ jobTriggered: 'Job ausgelost',
|
||||
backendTimeout: 'Zeituberschreitung beim Lesen der Datei',
|
||||
unsupportedBackend: 'Aktuelles Terminal-Backend unterstutzt keine Datei-Downloads',
|
||||
invalidPath: 'Ungultiger Dateipfad',
|
||||
contentDisplay: 'Inhalt anzeigen',
|
||||
download: 'Herunterladen',
|
||||
downloadFile: 'Datei herunterladen',
|
||||
},
|
||||
|
||||
@@ -1196,6 +1196,7 @@ export default {
|
||||
backendTimeout: 'File read timed out',
|
||||
unsupportedBackend: 'Current terminal backend does not support file download',
|
||||
invalidPath: 'Invalid file path',
|
||||
contentDisplay: 'Content display',
|
||||
download: 'Download',
|
||||
downloadFile: 'Download file',
|
||||
},
|
||||
|
||||
@@ -1161,6 +1161,7 @@ jobTriggered: 'Job ejecutado',
|
||||
backendTimeout: 'Tiempo de lectura del archivo agotado',
|
||||
unsupportedBackend: 'El backend del terminal actual no admite la descarga de archivos',
|
||||
invalidPath: 'Ruta de archivo invalida',
|
||||
contentDisplay: 'Contenido',
|
||||
download: 'Descargar',
|
||||
downloadFile: 'Descargar archivo',
|
||||
},
|
||||
|
||||
@@ -1161,6 +1161,7 @@ jobTriggered: 'Job declenche',
|
||||
backendTimeout: 'Delai de lecture du fichier depasse',
|
||||
unsupportedBackend: 'Le backend de terminal actuel ne prend pas en charge le telechargement de fichiers',
|
||||
invalidPath: 'Chemin de fichier invalide',
|
||||
contentDisplay: 'Affichage du contenu',
|
||||
download: 'Telecharger',
|
||||
downloadFile: 'Telecharger le fichier',
|
||||
},
|
||||
|
||||
@@ -1161,6 +1161,7 @@ export default {
|
||||
backendTimeout: 'ファイルの読み取りがタイムアウトしました',
|
||||
unsupportedBackend: '現在のターミナルバックエンドはファイルのダウンロードに対応していません',
|
||||
invalidPath: '無効なファイルパス',
|
||||
contentDisplay: '内容表示',
|
||||
download: 'ダウンロード',
|
||||
downloadFile: 'ファイルをダウンロード',
|
||||
},
|
||||
|
||||
@@ -1161,6 +1161,7 @@ export default {
|
||||
backendTimeout: '파일 읽기 시간 초과',
|
||||
unsupportedBackend: '현재 터미널 백엔드는 파일 다운로드를 지원하지 않습니다',
|
||||
invalidPath: '잘못된 파일 경로',
|
||||
contentDisplay: '내용 표시',
|
||||
download: '다운로드',
|
||||
downloadFile: '파일 다운로드',
|
||||
},
|
||||
|
||||
@@ -1161,6 +1161,7 @@ jobTriggered: 'Job acionado',
|
||||
backendTimeout: 'Tempo esgotado para ler o arquivo',
|
||||
unsupportedBackend: 'O backend de terminal atual nao suporta download de arquivos',
|
||||
invalidPath: 'Caminho de arquivo invalido',
|
||||
contentDisplay: 'Exibicao de conteudo',
|
||||
download: 'Baixar',
|
||||
downloadFile: 'Baixar arquivo',
|
||||
},
|
||||
|
||||
@@ -1201,6 +1201,7 @@ export default {
|
||||
backendTimeout: '檔案讀取逾時',
|
||||
unsupportedBackend: '目前 terminal backend 暫不支援檔案下載',
|
||||
invalidPath: '無效的檔案路徑',
|
||||
contentDisplay: '內容展示',
|
||||
download: '下載',
|
||||
downloadFile: '下載檔案',
|
||||
},
|
||||
|
||||
@@ -1198,6 +1198,7 @@ export default {
|
||||
backendTimeout: '文件读取超时',
|
||||
unsupportedBackend: '当前 terminal backend 暂不支持文件下载',
|
||||
invalidPath: '无效的文件路径',
|
||||
contentDisplay: '内容展示',
|
||||
download: '下载',
|
||||
downloadFile: '下载文件',
|
||||
},
|
||||
|
||||
@@ -12,6 +12,16 @@ vi.mock('vue-i18n', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', () => ({
|
||||
NDrawer: {
|
||||
props: ['show'],
|
||||
template: '<div v-if="show"><slot /></div>',
|
||||
},
|
||||
NDrawerContent: {
|
||||
template: '<section><slot /></section>',
|
||||
},
|
||||
NSpin: {
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
useMessage: () => ({
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
|
||||
@@ -10,6 +10,12 @@ const mermaidMock = vi.hoisted(() => ({
|
||||
})),
|
||||
}))
|
||||
|
||||
const downloadApiMock = vi.hoisted(() => ({
|
||||
downloadFile: vi.fn(() => Promise.resolve()),
|
||||
fetchFileText: vi.fn(() => Promise.resolve('preview content')),
|
||||
getDownloadUrl: vi.fn((path: string) => `http://test.local/api/hermes/download?path=${encodeURIComponent(path)}`),
|
||||
}))
|
||||
|
||||
vi.mock('mermaid', () => ({
|
||||
default: mermaidMock,
|
||||
}))
|
||||
@@ -28,6 +34,22 @@ vi.mock('vue-i18n', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', () => ({
|
||||
NDrawer: {
|
||||
props: ['show', 'width'],
|
||||
template: '<div v-if="show" class="n-drawer-stub" :data-width="width"><slot /></div>',
|
||||
},
|
||||
NDrawerContent: {
|
||||
props: {
|
||||
title: { type: String, default: '' },
|
||||
closable: { type: Boolean, default: false },
|
||||
bodyContentStyle: { type: [Object, String], default: undefined },
|
||||
},
|
||||
template: '<section class="n-drawer-content-stub" :data-body-padding="bodyContentStyle && bodyContentStyle.padding"><header class="n-drawer-header-stub">{{ title }}<button v-if="closable" class="n-drawer-close-stub" @click="$emit(\'close\')">x</button></header><slot /></section>',
|
||||
},
|
||||
NSpin: {
|
||||
props: ['show'],
|
||||
template: '<div class="n-spin-stub"><slot /></div>',
|
||||
},
|
||||
useMessage: () => ({
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
@@ -37,8 +59,9 @@ vi.mock('naive-ui', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/api/hermes/download', () => ({
|
||||
downloadFile: vi.fn(),
|
||||
getDownloadUrl: (path: string) => `http://test.local/api/hermes/download?path=${encodeURIComponent(path)}`,
|
||||
downloadFile: downloadApiMock.downloadFile,
|
||||
fetchFileText: downloadApiMock.fetchFileText,
|
||||
getDownloadUrl: downloadApiMock.getDownloadUrl,
|
||||
}))
|
||||
|
||||
import MarkdownRenderer from '@/components/hermes/chat/MarkdownRenderer.vue'
|
||||
@@ -51,9 +74,15 @@ describe('MarkdownRenderer', () => {
|
||||
beforeEach(() => {
|
||||
mermaidMock.initialize.mockClear()
|
||||
mermaidMock.render.mockClear()
|
||||
downloadApiMock.downloadFile.mockClear()
|
||||
downloadApiMock.fetchFileText.mockClear()
|
||||
downloadApiMock.getDownloadUrl.mockClear()
|
||||
mermaidMock.render.mockImplementation(async (id: string, source: string) => ({
|
||||
svg: `<svg id="${id}" data-testid="mermaid-svg"><text>${source}</text></svg>`,
|
||||
}))
|
||||
downloadApiMock.downloadFile.mockResolvedValue(undefined)
|
||||
downloadApiMock.fetchFileText.mockResolvedValue('preview content')
|
||||
downloadApiMock.getDownloadUrl.mockImplementation((path: string) => `http://test.local/api/hermes/download?path=${encodeURIComponent(path)}`)
|
||||
|
||||
Object.defineProperty(window, 'isSecureContext', {
|
||||
configurable: true,
|
||||
@@ -253,6 +282,69 @@ describe('MarkdownRenderer', () => {
|
||||
expect(img.attributes('alt')).toBe('桌面截图')
|
||||
})
|
||||
|
||||
it('downloads local text files when the file card download icon is clicked', async () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: '[notes.txt](/tmp/notes.txt)',
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.markdown-file-card').exists()).toBe(true)
|
||||
expect(wrapper.find('.att-download-btn .att-download-icon').exists()).toBe(true)
|
||||
|
||||
await wrapper.find('.att-download-btn').trigger('click')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(downloadApiMock.downloadFile).toHaveBeenCalledTimes(1)
|
||||
expect(downloadApiMock.downloadFile).toHaveBeenCalledWith('/tmp/notes.txt', 'notes.txt')
|
||||
expect(downloadApiMock.fetchFileText).not.toHaveBeenCalled()
|
||||
expect(wrapper.find('.n-drawer-stub').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('opens text previews in a responsive drawer with a close control', async () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: '[notes.txt](/tmp/notes.txt)',
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.find('.markdown-file-card').trigger('click')
|
||||
await Promise.resolve()
|
||||
await nextTick()
|
||||
|
||||
const drawer = wrapper.find('.n-drawer-stub')
|
||||
expect(drawer.exists()).toBe(true)
|
||||
expect(drawer.attributes('data-width')).toBe('min(800px, 100vw)')
|
||||
expect(drawer.find('.n-drawer-content-stub').attributes('data-body-padding')).toBe('0')
|
||||
expect(drawer.text()).toContain('download.contentDisplay')
|
||||
expect(downloadApiMock.fetchFileText).toHaveBeenCalledWith('/tmp/notes.txt', 'notes.txt')
|
||||
|
||||
await drawer.find('.n-drawer-close-stub').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('.n-drawer-stub').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders markdown file previews as markdown content', async () => {
|
||||
downloadApiMock.fetchFileText.mockResolvedValue('# Preview Title\n\n**bold text**')
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: '[notes.md](/tmp/notes.md)',
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.find('.markdown-file-card').trigger('click')
|
||||
await Promise.resolve()
|
||||
await nextTick()
|
||||
|
||||
const drawer = wrapper.find('.n-drawer-stub')
|
||||
expect(drawer.exists()).toBe(true)
|
||||
expect(drawer.find('.text-preview-markdown').exists()).toBe(true)
|
||||
expect(drawer.find('.text-preview-body').exists()).toBe(false)
|
||||
expect(drawer.find('.text-preview-markdown h1').text()).toBe('Preview Title')
|
||||
expect(drawer.find('.text-preview-markdown strong').text()).toBe('bold text')
|
||||
})
|
||||
|
||||
it('keeps tilde-fenced markdown examples with nested tilde fences intact', () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
|
||||
Reference in New Issue
Block a user