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
This commit is contained in:
RieN 7z 2025-09-09 16:31:42 +08:00 committed by GitHub
parent 9b1aa3cd36
commit 0c589a6f79
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 200 additions and 4 deletions

View File

@ -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<Props> = ({ text, title, extension, resolve }) => {
const [open, setOpen] = useState(true)
const onOk = () => {
setOpen(false)
}
const onCancel = () => {
setOpen(false)
}
const onClose = () => {
resolve({})
}
TextFilePreviewPopup.hide = onCancel
return (
<Modal
open={open}
onOk={onOk}
onCancel={onCancel}
afterClose={onClose}
title={title}
width={700}
transitionName="animation-move-down"
styles={{
content: {
borderRadius: 20,
padding: 0,
overflow: 'hidden'
},
body: {
height: '80vh',
maxHeight: 'inherit',
padding: 0
}
}}
centered
closable={true}
footer={null}>
{extension !== undefined ? (
<Editor
editable={false}
expanded={false}
height="100%"
style={{ height: '100%' }}
value={text}
language={extension}
/>
) : (
<Text>{text}</Text>
)}
</Modal>
)
}
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<any>((resolve) => {
TopView.show(
<PopupContainer
text={text}
title={title}
extension={extension}
resolve={(v) => {
resolve(v)
TopView.hide('TextFilePreviewPopup')
}}
/>,
'TextFilePreviewPopup'
)
})
}
}

View File

@ -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
}
}

View File

@ -947,6 +947,9 @@
"image": "Image",
"name": "Name",
"open": "Open",
"preview": {
"error": "Failed to open file"
},
"size": "Size",
"text": "Text",
"title": "Files",

View File

@ -947,6 +947,9 @@
"image": "图片",
"name": "文件名",
"open": "打开",
"preview": {
"error": "打开文件失败"
},
"size": "大小",
"text": "文本",
"title": "文件",

View File

@ -947,6 +947,9 @@
"image": "圖片",
"name": "名稱",
"open": "開啟",
"preview": {
"error": "開啟檔案失敗"
},
"size": "大小",
"text": "文字",
"title": "檔案",

View File

@ -943,6 +943,9 @@
"image": "Εικόνα",
"name": "Όνομα αρχείου",
"open": "Άνοιγμα",
"preview": {
"error": "Αποτυχία ανοίγματος του αρχείου"
},
"size": "Μέγεθος",
"text": "Κείμενο",
"title": "Αρχεία",

View File

@ -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",

View File

@ -943,6 +943,9 @@
"image": "Image",
"name": "Nom du fichier",
"open": "Ouvrir",
"preview": {
"error": "Échec de louverture du fichier"
},
"size": "Taille",
"text": "Texte",
"title": "Fichier",

View File

@ -943,6 +943,9 @@
"image": "画像",
"name": "名前",
"open": "開く",
"preview": {
"error": "ファイルを開くのに失敗しました"
},
"size": "サイズ",
"text": "テキスト",
"title": "ファイル",

View File

@ -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",

View File

@ -943,6 +943,9 @@
"image": "Изображение",
"name": "Имя",
"open": "Открыть",
"preview": {
"error": "Не удалось открыть файл"
},
"size": "Размер",
"text": "Текст",
"title": "Файлы",

View File

@ -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<boolean>(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}

View File

@ -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<Props> = ({ block }) => {
const { preview } = useAttachment()
if (!block.file) {
return null
}
@ -34,9 +38,26 @@ const MessageAttachments: FC<Props> = ({ 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)
}}
/>
</Container>
)

View File

@ -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
}