From 0c589a6f796cae39361bda90b40e652714d275a8 Mon Sep 17 00:00:00 2001 From: RieN 7z Date: Tue, 9 Sep 2025 16:31:42 +0800 Subject: [PATCH] feat: open message text file attachment in preview (#9644) * feat: add text file preview (#7023) * feat: open message text file attachment in preview * refractor: use `window.api.fs.readText` * fix: use `FileTypes.TEXT` * fix: trim prefix "file://" with `replace` * refactor(FileAction): centralize file click handling for text preview - Use FileAction.handleClick in AttachmentPreview and MessageAttachments - Show i18n error modal on failure (zh-cn: files.click.error) * fix: i18n * fix: update i18n on field `files.click.error` with codex * fix: use hook * fix: rename `handleClick` to `preview` * feat: support lang highlight * fix: remove prefix '.' of extension * fix: code editor style * fix: editor cursor text style * fix: add `FileTypes` check * fix: move parseFileType into utils * fix: move `parseFileTypes` into utils/file --- .../src/components/Popups/TextFilePreview.tsx | 105 ++++++++++++++++++ src/renderer/src/hooks/useAttachment.ts | 35 ++++++ src/renderer/src/i18n/locales/en-us.json | 3 + src/renderer/src/i18n/locales/zh-cn.json | 3 + src/renderer/src/i18n/locales/zh-tw.json | 3 + src/renderer/src/i18n/translate/el-gr.json | 3 + src/renderer/src/i18n/translate/es-es.json | 3 + src/renderer/src/i18n/translate/fr-fr.json | 3 + src/renderer/src/i18n/translate/ja-jp.json | 3 + src/renderer/src/i18n/translate/pt-pt.json | 3 + src/renderer/src/i18n/translate/ru-ru.json | 3 + .../pages/home/Inputbar/AttachmentPreview.tsx | 7 +- .../home/Messages/MessageAttachments.tsx | 23 +++- src/renderer/src/utils/file.ts | 7 ++ 14 files changed, 200 insertions(+), 4 deletions(-) create mode 100644 src/renderer/src/components/Popups/TextFilePreview.tsx create mode 100644 src/renderer/src/hooks/useAttachment.ts diff --git a/src/renderer/src/components/Popups/TextFilePreview.tsx b/src/renderer/src/components/Popups/TextFilePreview.tsx new file mode 100644 index 0000000000..f5fa787d0b --- /dev/null +++ b/src/renderer/src/components/Popups/TextFilePreview.tsx @@ -0,0 +1,105 @@ +import { Modal } from 'antd' +import { useState } from 'react' +import styled from 'styled-components' + +import CodeEditor from '../CodeEditor' +import { TopView } from '../TopView' + +interface Props { + text: string + title: string + extension?: string + resolve: (data: any) => void +} + +const PopupContainer: React.FC = ({ text, title, extension, resolve }) => { + const [open, setOpen] = useState(true) + + const onOk = () => { + setOpen(false) + } + + const onCancel = () => { + setOpen(false) + } + + const onClose = () => { + resolve({}) + } + + TextFilePreviewPopup.hide = onCancel + + return ( + + {extension !== undefined ? ( + + ) : ( + {text} + )} + + ) +} + +const Text = styled.div` + padding: 16px; + white-space: pre; + cursor: text; +` + +const Editor = styled(CodeEditor)` + .cm-line { + cursor: text; + } +` + +export default class TextFilePreviewPopup { + static topviewId = 0 + static hide() { + TopView.hide('TextFilePreviewPopup') + } + static show(text: string, title: string, extension?: string) { + return new Promise((resolve) => { + TopView.show( + { + resolve(v) + TopView.hide('TextFilePreviewPopup') + }} + />, + 'TextFilePreviewPopup' + ) + }) + } +} diff --git a/src/renderer/src/hooks/useAttachment.ts b/src/renderer/src/hooks/useAttachment.ts new file mode 100644 index 0000000000..0d017ec6bf --- /dev/null +++ b/src/renderer/src/hooks/useAttachment.ts @@ -0,0 +1,35 @@ +import { loggerService } from '@logger' +import TextFilePreviewPopup from '@renderer/components/Popups/TextFilePreview' +import { FileTypes } from '@renderer/types' +import { useTranslation } from 'react-i18next' + +const logger = loggerService.withContext('FileAction') + +/** + * 处理附件点击事件: + * 如果是文本文件,在 Preview 视图中打开, + * 否则使用默认打开接口 + */ +export function useAttachment() { + const { t } = useTranslation() + const preview = async (path: string, title: string, fileType: FileTypes, extension?: string) => { + try { + if (fileType === FileTypes.TEXT) { + const content = await window.api.fs.readText(path) + let ext = extension + if (ext?.startsWith('.')) { + ext = ext.replace('.', '') + } + TextFilePreviewPopup.show(content, title, ext) + } else { + window.api.file.openPath(path) + } + } catch (err) { + logger.error(`Error opening ${path}:`, err as Error) + window.modal.error({ content: t('files.preview.error'), centered: true }) + } + } + return { + preview + } +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 287e513048..32fb1fd7e5 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -947,6 +947,9 @@ "image": "Image", "name": "Name", "open": "Open", + "preview": { + "error": "Failed to open file" + }, "size": "Size", "text": "Text", "title": "Files", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 9bc972cc23..b432dac525 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -947,6 +947,9 @@ "image": "图片", "name": "文件名", "open": "打开", + "preview": { + "error": "打开文件失败" + }, "size": "大小", "text": "文本", "title": "文件", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 05583836c4..73288e916f 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -947,6 +947,9 @@ "image": "圖片", "name": "名稱", "open": "開啟", + "preview": { + "error": "開啟檔案失敗" + }, "size": "大小", "text": "文字", "title": "檔案", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index b85f5c9b50..2403c487b5 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -943,6 +943,9 @@ "image": "Εικόνα", "name": "Όνομα αρχείου", "open": "Άνοιγμα", + "preview": { + "error": "Αποτυχία ανοίγματος του αρχείου" + }, "size": "Μέγεθος", "text": "Κείμενο", "title": "Αρχεία", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 95d79571a1..f25b2be8f5 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -943,6 +943,9 @@ "image": "Imagen", "name": "Nombre del archivo", "open": "Abrir", + "preview": { + "error": "No se pudo abrir el archivo" + }, "size": "Tamaño", "text": "Texto", "title": "Archivo", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index d3ae5f5dfb..36f9cc192b 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -943,6 +943,9 @@ "image": "Image", "name": "Nom du fichier", "open": "Ouvrir", + "preview": { + "error": "Échec de l’ouverture du fichier" + }, "size": "Taille", "text": "Texte", "title": "Fichier", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 30ea6e3411..2635cf6500 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -943,6 +943,9 @@ "image": "画像", "name": "名前", "open": "開く", + "preview": { + "error": "ファイルを開くのに失敗しました" + }, "size": "サイズ", "text": "テキスト", "title": "ファイル", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 2d315e8fc8..227f5b3c4b 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -943,6 +943,9 @@ "image": "Imagem", "name": "Nome do Arquivo", "open": "Abrir", + "preview": { + "error": "Falha ao abrir o arquivo" + }, "size": "Tamanho", "text": "Texto", "title": "Arquivo", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 045d8d618f..903d809a13 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -943,6 +943,9 @@ "image": "Изображение", "name": "Имя", "open": "Открыть", + "preview": { + "error": "Не удалось открыть файл" + }, "size": "Размер", "text": "Текст", "title": "Файлы", diff --git a/src/renderer/src/pages/home/Inputbar/AttachmentPreview.tsx b/src/renderer/src/pages/home/Inputbar/AttachmentPreview.tsx index 7df45c63ae..27fe399c8e 100644 --- a/src/renderer/src/pages/home/Inputbar/AttachmentPreview.tsx +++ b/src/renderer/src/pages/home/Inputbar/AttachmentPreview.tsx @@ -13,6 +13,7 @@ import { LinkOutlined } from '@ant-design/icons' import CustomTag from '@renderer/components/Tags/CustomTag' +import { useAttachment } from '@renderer/hooks/useAttachment' import FileManager from '@renderer/services/FileManager' import { FileMetadata } from '@renderer/types' import { formatFileSize } from '@renderer/utils' @@ -81,6 +82,7 @@ export const getFileIcon = (type?: string) => { } export const FileNameRender: FC<{ file: FileMetadata }> = ({ file }) => { + const { preview } = useAttachment() const [visible, setVisible] = useState(false) const isImage = (ext: string) => { return ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'].includes(ext.toLocaleLowerCase()) @@ -121,9 +123,8 @@ export const FileNameRender: FC<{ file: FileMetadata }> = ({ file }) => { return } const path = FileManager.getSafePath(file) - if (path) { - window.api.file.openPath(path) - } + const name = FileManager.formatFileName(file) + preview(path, name, file.type, file.ext) }} title={fullName}> {displayName} diff --git a/src/renderer/src/pages/home/Messages/MessageAttachments.tsx b/src/renderer/src/pages/home/Messages/MessageAttachments.tsx index fea431b6ca..73fcd4ea58 100644 --- a/src/renderer/src/pages/home/Messages/MessageAttachments.tsx +++ b/src/renderer/src/pages/home/Messages/MessageAttachments.tsx @@ -1,6 +1,9 @@ +import { useAttachment } from '@renderer/hooks/useAttachment' import FileManager from '@renderer/services/FileManager' import type { FileMessageBlock } from '@renderer/types/newMessage' +import { parseFileTypes } from '@renderer/utils' import { Upload } from 'antd' +import { t } from 'i18next' import { FC } from 'react' import styled from 'styled-components' @@ -20,6 +23,7 @@ const StyledUpload = styled(Upload)` ` const MessageAttachments: FC = ({ block }) => { + const { preview } = useAttachment() if (!block.file) { return null } @@ -34,9 +38,26 @@ const MessageAttachments: FC = ({ block }) => { uid: block.file.id, url: 'file://' + FileManager.getSafePath(block.file), status: 'done' as const, - name: FileManager.formatFileName(block.file) + name: FileManager.formatFileName(block.file), + type: block.file.type, + preview: block.file.ext } ]} + onPreview={(file) => { + if (file.url === undefined || file.type === undefined) { + return + } + const fileType = parseFileTypes(file.type) + if (fileType === null) { + window.modal.error({ content: t('files.preview.error'), centered: true }) + return + } + let path = file.url + if (path.startsWith('file://')) { + path = path.replace('file://', '') + } + preview(path, file.name, fileType, file.preview) + }} /> ) diff --git a/src/renderer/src/utils/file.ts b/src/renderer/src/utils/file.ts index 4b3590b7d4..a02d6c3a6e 100644 --- a/src/renderer/src/utils/file.ts +++ b/src/renderer/src/utils/file.ts @@ -129,3 +129,10 @@ export const mime2type = (mimeStr: string): FileTypes => { } return FileTypes.OTHER } + +export function parseFileTypes(str: string): FileTypes | null { + if (Object.values(FileTypes).includes(str as FileTypes)) { + return str as FileTypes + } + return null +}