mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
feat(desktop): add local attachment open actions
This commit is contained in:
parent
80d4f1186c
commit
d9e30cebcc
@ -1,14 +1,43 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import '@testing-library/jest-dom'
|
||||
import { fireEvent, render } from '@testing-library/react'
|
||||
import { fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { browserHost } from '../../lib/desktopHost/browserHost'
|
||||
import { useOpenTargetStore } from '../../stores/openTargetStore'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useUIStore } from '../../stores/uiStore'
|
||||
import { AttachmentGallery } from './AttachmentGallery'
|
||||
|
||||
describe('AttachmentGallery', () => {
|
||||
const openPath = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
useUIStore.setState({ toasts: [] })
|
||||
useOpenTargetStore.setState({
|
||||
targets: [
|
||||
{ id: 'code', kind: 'ide', label: 'VS Code', icon: 'vscode', platform: 'darwin' },
|
||||
{ id: 'finder', kind: 'file_manager', label: 'Finder', icon: 'finder', platform: 'darwin' },
|
||||
],
|
||||
fetchedAt: Date.now(),
|
||||
loading: false,
|
||||
error: null,
|
||||
})
|
||||
window.desktopHost = {
|
||||
...browserHost,
|
||||
kind: 'electron',
|
||||
isDesktop: true,
|
||||
capabilities: {
|
||||
...browserHost.capabilities,
|
||||
shell: true,
|
||||
},
|
||||
shell: {
|
||||
...browserHost.shell,
|
||||
openPath,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
it('renders diff comments as note-first composer cards with side-aware locations', () => {
|
||||
@ -76,6 +105,114 @@ describe('AttachmentGallery', () => {
|
||||
expect(document.body.textContent).not.toContain(':L')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['report.pdf', 'PDF', 'picture_as_pdf'],
|
||||
['brief.docx', 'DOCX', 'docs'],
|
||||
['budget.xlsx', 'XLSX', 'table_chart'],
|
||||
['launch.pptx', 'PPTX', 'slideshow'],
|
||||
['sources.zip', 'ZIP', 'folder_zip'],
|
||||
['notes.md', 'MD', 'markdown'],
|
||||
])('renders a type-specific visual for %s', (name, extension, icon) => {
|
||||
const view = render(
|
||||
<AttachmentGallery
|
||||
attachments={[{
|
||||
type: 'file',
|
||||
name,
|
||||
}]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(view.container.querySelector(`[data-file-extension="${extension}"]`)).toBeInTheDocument()
|
||||
expect(view.getByText(icon)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens an absolute desktop attachment with the system default app', async () => {
|
||||
const path = '/Users/example/Desktop/report.pdf'
|
||||
const view = render(
|
||||
<AttachmentGallery
|
||||
attachments={[{
|
||||
type: 'file',
|
||||
name: 'report.pdf',
|
||||
path,
|
||||
}]}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(view.getByRole('button', { name: 'Open report.pdf' }))
|
||||
|
||||
await waitFor(() => expect(openPath).toHaveBeenCalledWith(path))
|
||||
})
|
||||
|
||||
it('keeps relative and pathless attachments non-interactive', () => {
|
||||
const view = render(
|
||||
<AttachmentGallery
|
||||
attachments={[
|
||||
{ type: 'file', name: 'relative.md', path: 'docs/relative.md' },
|
||||
{ type: 'file', name: 'detached.pdf' },
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(view.queryByRole('button', { name: 'Open relative.md' })).not.toBeInTheDocument()
|
||||
expect(view.queryByRole('button', { name: 'Open detached.pdf' })).not.toBeInTheDocument()
|
||||
expect(openPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps absolute attachments non-interactive outside the desktop runtime', () => {
|
||||
window.desktopHost = browserHost
|
||||
const view = render(
|
||||
<AttachmentGallery
|
||||
attachments={[{
|
||||
type: 'file',
|
||||
name: 'report.pdf',
|
||||
path: '/Users/example/Desktop/report.pdf',
|
||||
}]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(view.queryByRole('button', { name: 'Open report.pdf' })).not.toBeInTheDocument()
|
||||
expect(view.queryByRole('button', { name: 'Open with' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a localized toast when the local file cannot be opened', async () => {
|
||||
openPath.mockRejectedValueOnce(new Error('missing'))
|
||||
const view = render(
|
||||
<AttachmentGallery
|
||||
attachments={[{
|
||||
type: 'file',
|
||||
name: 'missing.pdf',
|
||||
path: '/Users/example/Desktop/missing.pdf',
|
||||
}]}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(view.getByRole('button', { name: 'Open missing.pdf' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useUIStore.getState().toasts.at(-1)?.message).toBe(
|
||||
'Could not open missing.pdf. The file may have been moved or deleted.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('reuses the Open With menu for IDE and file-manager destinations', async () => {
|
||||
const view = render(
|
||||
<AttachmentGallery
|
||||
attachments={[{
|
||||
type: 'file',
|
||||
name: 'report.pdf',
|
||||
path: '/Users/example/Desktop/report.pdf',
|
||||
}]}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(view.getByRole('button', { name: 'Open with' }))
|
||||
|
||||
expect(await view.findByText('Open in VS Code')).toBeInTheDocument()
|
||||
expect(view.getByText('Reveal in Finder')).toBeInTheDocument()
|
||||
expect(openPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes a quoted workspace attachment by id', () => {
|
||||
const onRemove = vi.fn()
|
||||
|
||||
|
||||
@ -1,6 +1,13 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { MessageSquare, X } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react'
|
||||
import { ChevronDown, MessageSquare, X } from 'lucide-react'
|
||||
import { useTranslation, type TranslationKey } from '../../i18n'
|
||||
import { getDesktopHost } from '../../lib/desktopHost'
|
||||
import { isAbsoluteLocalPath } from '../../lib/handlePreviewLink'
|
||||
import { buildOpenWithItems, describeFileType, type OpenWithItem } from '../../lib/openWithItems'
|
||||
import { useOpenTargetStore } from '../../stores/openTargetStore'
|
||||
import { useUIStore } from '../../stores/uiStore'
|
||||
import { OpenWithMenu } from '../common/OpenWithMenu'
|
||||
import { ImageGalleryModal } from './ImageGalleryModal'
|
||||
|
||||
export type AttachmentPreview = {
|
||||
@ -9,6 +16,7 @@ export type AttachmentPreview = {
|
||||
name: string
|
||||
path?: string
|
||||
data?: string
|
||||
mimeType?: string
|
||||
previewUrl?: string
|
||||
isDirectory?: boolean
|
||||
lineStart?: number
|
||||
@ -19,6 +27,27 @@ export type AttachmentPreview = {
|
||||
quote?: string
|
||||
}
|
||||
|
||||
const FILE_ICON_ACCENTS: Record<string, string> = {
|
||||
picture_as_pdf: '#d14343',
|
||||
docs: '#3f6ecf',
|
||||
markdown: '#4d6b8a',
|
||||
text_snippet: '#667085',
|
||||
table_chart: '#24845b',
|
||||
slideshow: '#c85b2b',
|
||||
folder_zip: '#a46a17',
|
||||
code: '#7656b5',
|
||||
audio_file: '#ad477c',
|
||||
video_file: '#6655b8',
|
||||
html: '#c05d2c',
|
||||
image: '#24899a',
|
||||
folder: '#a46a17',
|
||||
insert_drive_file: '#667085',
|
||||
}
|
||||
|
||||
function fileIconAccent(icon: string): string {
|
||||
return FILE_ICON_ACCENTS[icon] ?? FILE_ICON_ACCENTS.insert_drive_file!
|
||||
}
|
||||
|
||||
type Props = {
|
||||
attachments: AttachmentPreview[]
|
||||
variant?: 'composer' | 'message'
|
||||
@ -28,6 +57,8 @@ type Props = {
|
||||
export function AttachmentGallery({ attachments, variant = 'message', onRemove }: Props) {
|
||||
const t = useTranslation()
|
||||
const [activeImageIndex, setActiveImageIndex] = useState<number | null>(null)
|
||||
const [openWith, setOpenWith] = useState<{ items: OpenWithItem[]; anchor: DOMRect; triggerEl: HTMLElement } | null>(null)
|
||||
const desktopHost = getDesktopHost()
|
||||
|
||||
const images = useMemo(
|
||||
() =>
|
||||
@ -44,6 +75,53 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
|
||||
|
||||
const isComposer = variant === 'composer'
|
||||
|
||||
const showOpenFailure = (name: string) => {
|
||||
useUIStore.getState().addToast({
|
||||
type: 'error',
|
||||
message: t('attachments.openFailed', { name }),
|
||||
})
|
||||
}
|
||||
|
||||
const openLocalAttachment = (attachment: AttachmentPreview) => {
|
||||
if (!attachment.path) return
|
||||
void desktopHost.shell.openPath(attachment.path).catch(() => showOpenFailure(attachment.name))
|
||||
}
|
||||
|
||||
const openAttachmentWith = (
|
||||
event: ReactMouseEvent<HTMLButtonElement>,
|
||||
attachment: AttachmentPreview,
|
||||
) => {
|
||||
event.stopPropagation()
|
||||
if (!attachment.path) return
|
||||
if (openWith) {
|
||||
setOpenWith(null)
|
||||
return
|
||||
}
|
||||
|
||||
const triggerEl = event.currentTarget
|
||||
const anchor = triggerEl.getBoundingClientRect()
|
||||
void (async () => {
|
||||
await useOpenTargetStore.getState().ensureTargets()
|
||||
const items = buildOpenWithItems(
|
||||
{ kind: 'file', absolutePath: attachment.path! },
|
||||
useOpenTargetStore.getState().targets,
|
||||
{
|
||||
openInAppBrowser: () => {},
|
||||
openSystem: (path) => {
|
||||
void desktopHost.shell.openPath(path).catch(() => showOpenFailure(attachment.name))
|
||||
},
|
||||
openWorkspacePreview: () => {},
|
||||
openTarget: (targetId, path) => {
|
||||
void useOpenTargetStore.getState().openTarget(targetId, path)
|
||||
.catch(() => showOpenFailure(attachment.name))
|
||||
},
|
||||
t: (key, vars) => t(key as TranslationKey, vars),
|
||||
},
|
||||
)
|
||||
if (items.length > 0) setOpenWith({ items, anchor, triggerEl })
|
||||
})()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={isComposer ? 'flex flex-wrap items-center gap-2' : 'flex flex-wrap justify-end gap-2'}>
|
||||
@ -190,31 +268,85 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
|
||||
: ''
|
||||
const quotePreview = attachment.quote?.trim().replace(/\s+/g, ' ')
|
||||
const hasQuotePreview = !!quotePreview
|
||||
const typeInfo = describeFileType(attachment.path || attachment.name)
|
||||
const fileIcon = attachment.isDirectory ? 'folder' : typeInfo.icon
|
||||
const typeLabel = attachment.isDirectory
|
||||
? t('openWith.fileType.file')
|
||||
: typeInfo.ext || t(typeInfo.categoryKey as TranslationKey)
|
||||
const canOpenLocally =
|
||||
!isComposer &&
|
||||
!!attachment.path &&
|
||||
isAbsoluteLocalPath(attachment.path) &&
|
||||
desktopHost.isDesktop &&
|
||||
desktopHost.capabilities.shell
|
||||
|
||||
return (
|
||||
<div
|
||||
key={attachment.id || `${attachment.name}-${index}`}
|
||||
className={[
|
||||
'group/file inline-flex max-w-full min-w-0 border border-[var(--color-border)]',
|
||||
'bg-[var(--color-surface-container-low)] text-[var(--color-text-secondary)] shadow-[0_1px_2px_rgba(0,0,0,0.04)]',
|
||||
hasQuotePreview
|
||||
? 'items-start gap-2 rounded-[8px] px-2.5 py-2'
|
||||
: 'h-9 items-center gap-2 rounded-full px-3',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className={`material-symbols-outlined shrink-0 text-[17px] text-[var(--color-text-tertiary)] ${hasQuotePreview ? 'mt-0.5' : ''}`}>
|
||||
{hasQuotePreview ? 'chat_bubble' : attachment.isDirectory ? 'folder' : 'description'}
|
||||
const fileVisual = (
|
||||
<>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[9px] bg-[var(--color-surface)] shadow-[inset_0_0_0_1px_var(--color-border)]"
|
||||
style={{ color: fileIconAccent(fileIcon) }}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[19px]">{fileIcon}</span>
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block min-w-0 max-w-[260px] truncate text-[13px] font-medium leading-5 text-[var(--color-text-primary)]">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block min-w-0 max-w-[260px] truncate text-[13px] font-semibold leading-5 text-[var(--color-text-primary)]">
|
||||
{attachment.name}{lineLabel}
|
||||
</span>
|
||||
<span className="block truncate text-[10px] font-semibold uppercase leading-3 tracking-[0.08em] text-[var(--color-text-tertiary)]">
|
||||
{typeLabel}
|
||||
</span>
|
||||
{hasQuotePreview && (
|
||||
<span className="mt-0.5 block max-w-[320px] truncate font-[var(--font-mono)] text-[11px] leading-4 text-[var(--color-text-tertiary)]">
|
||||
{quotePreview}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
|
||||
if (canOpenLocally) return (
|
||||
<div
|
||||
key={attachment.id || `${attachment.name}-${index}`}
|
||||
data-file-extension={typeInfo.ext || undefined}
|
||||
className={[
|
||||
'group/file inline-flex max-w-full min-w-[220px] items-stretch overflow-hidden rounded-[12px] border border-[var(--color-border)]',
|
||||
'bg-[var(--color-surface-container-low)] text-[var(--color-text-secondary)] shadow-[0_1px_2px_rgba(0,0,0,0.04)]',
|
||||
'transition-colors hover:border-[var(--color-brand)]/35 hover:bg-[var(--color-surface-container)]',
|
||||
].join(' ')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openLocalAttachment(attachment)}
|
||||
aria-label={t('attachments.open', { name: attachment.name })}
|
||||
title={attachment.path}
|
||||
className="flex min-h-12 min-w-0 flex-1 items-center gap-2.5 px-2.5 py-1.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/40"
|
||||
>
|
||||
{fileVisual}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => openAttachmentWith(event, attachment)}
|
||||
aria-label={t('openWith.title')}
|
||||
title={t('openWith.title')}
|
||||
className="flex w-9 shrink-0 items-center justify-center border-l border-[var(--color-border)] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/40"
|
||||
>
|
||||
<ChevronDown size={14} strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={attachment.id || `${attachment.name}-${index}`}
|
||||
data-file-extension={typeInfo.ext || undefined}
|
||||
className={[
|
||||
'group/file inline-flex max-w-full min-w-0 items-center gap-2.5 border border-[var(--color-border)]',
|
||||
'rounded-[12px] bg-[var(--color-surface-container-low)] px-2.5 py-1.5 text-[var(--color-text-secondary)]',
|
||||
'shadow-[0_1px_2px_rgba(0,0,0,0.04)]',
|
||||
].join(' ')}
|
||||
>
|
||||
{fileVisual}
|
||||
{onRemove && attachment.id && (
|
||||
<button
|
||||
type="button"
|
||||
@ -239,6 +371,14 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
|
||||
onSelect={setActiveImageIndex}
|
||||
/>
|
||||
)}
|
||||
{openWith && (
|
||||
<OpenWithMenu
|
||||
items={openWith.items}
|
||||
anchor={openWith.anchor}
|
||||
triggerEl={openWith.triggerEl}
|
||||
onClose={() => setOpenWith(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -118,6 +118,11 @@ export const en = {
|
||||
'openWith.fileType.web': 'Web',
|
||||
'openWith.fileType.image': 'Image',
|
||||
'openWith.fileType.code': 'Code',
|
||||
'openWith.fileType.spreadsheet': 'Spreadsheet',
|
||||
'openWith.fileType.presentation': 'Presentation',
|
||||
'openWith.fileType.archive': 'Archive',
|
||||
'openWith.fileType.audio': 'Audio',
|
||||
'openWith.fileType.video': 'Video',
|
||||
'openWith.fileType.file': 'File',
|
||||
|
||||
// ─── Assistant Output Targets ──────────────────────
|
||||
@ -207,6 +212,8 @@ export const en = {
|
||||
'workspace.diffReview.diffChanged': 'Diff changed. Select lines again to submit this comment.',
|
||||
'workspace.diffReview.collapsedSelection': 'Selection is outside the collapsed preview. Select visible lines again to submit this comment.',
|
||||
'attachments.remove': 'Remove {name}',
|
||||
'attachments.open': 'Open {name}',
|
||||
'attachments.openFailed': 'Could not open {name}. The file may have been moved or deleted.',
|
||||
|
||||
// ─── Status Bar ──────────────────────────────────────
|
||||
'status.connected': 'Connected',
|
||||
|
||||
@ -120,6 +120,11 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'openWith.fileType.web': 'Web',
|
||||
'openWith.fileType.image': '画像',
|
||||
'openWith.fileType.code': 'コード',
|
||||
'openWith.fileType.spreadsheet': 'スプレッドシート',
|
||||
'openWith.fileType.presentation': 'プレゼンテーション',
|
||||
'openWith.fileType.archive': 'アーカイブ',
|
||||
'openWith.fileType.audio': 'オーディオ',
|
||||
'openWith.fileType.video': 'ビデオ',
|
||||
'openWith.fileType.file': 'ファイル',
|
||||
|
||||
// ─── Assistant Output Targets ──────────────────────
|
||||
@ -209,6 +214,8 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'workspace.diffReview.diffChanged': '差分が更新されました。このコメントを送信するには行を選択し直してください。',
|
||||
'workspace.diffReview.collapsedSelection': '選択した行は折りたたまれたプレビューの範囲外です。表示中の行を選択し直してください。',
|
||||
'attachments.remove': '{name} を削除',
|
||||
'attachments.open': '{name} を開く',
|
||||
'attachments.openFailed': '{name} を開けませんでした。ファイルが移動または削除された可能性があります。',
|
||||
|
||||
// ─── Status Bar ──────────────────────────────────────
|
||||
'status.connected': '接続済み',
|
||||
|
||||
@ -120,6 +120,11 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'openWith.fileType.web': '웹',
|
||||
'openWith.fileType.image': '이미지',
|
||||
'openWith.fileType.code': '코드',
|
||||
'openWith.fileType.spreadsheet': '스프레드시트',
|
||||
'openWith.fileType.presentation': '프레젠테이션',
|
||||
'openWith.fileType.archive': '압축 파일',
|
||||
'openWith.fileType.audio': '오디오',
|
||||
'openWith.fileType.video': '비디오',
|
||||
'openWith.fileType.file': '파일',
|
||||
|
||||
// ─── Assistant Output Targets ──────────────────────
|
||||
@ -209,6 +214,8 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'workspace.diffReview.diffChanged': 'Diff가 변경되었습니다. 이 댓글을 제출하려면 줄을 다시 선택하세요.',
|
||||
'workspace.diffReview.collapsedSelection': '선택한 줄이 접힌 미리보기 범위 밖에 있습니다. 표시된 줄을 다시 선택하세요.',
|
||||
'attachments.remove': '{name} 제거',
|
||||
'attachments.open': '{name} 열기',
|
||||
'attachments.openFailed': '{name}을(를) 열 수 없습니다. 파일이 이동되었거나 삭제되었을 수 있습니다.',
|
||||
|
||||
// ─── Status Bar ──────────────────────────────────────
|
||||
'status.connected': '연결됨',
|
||||
|
||||
@ -120,6 +120,11 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'openWith.fileType.web': '網頁',
|
||||
'openWith.fileType.image': '圖片',
|
||||
'openWith.fileType.code': '程式碼',
|
||||
'openWith.fileType.spreadsheet': '試算表',
|
||||
'openWith.fileType.presentation': '簡報',
|
||||
'openWith.fileType.archive': '壓縮檔',
|
||||
'openWith.fileType.audio': '音訊',
|
||||
'openWith.fileType.video': '影片',
|
||||
'openWith.fileType.file': '檔案',
|
||||
|
||||
// ─── Assistant Output Targets ──────────────────────
|
||||
@ -209,6 +214,8 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'workspace.diffReview.diffChanged': '差異已更新。請重新選擇行以提交此評論。',
|
||||
'workspace.diffReview.collapsedSelection': '所選行不在收合後的預覽中。請重新選擇可見行以提交此評論。',
|
||||
'attachments.remove': '移除 {name}',
|
||||
'attachments.open': '開啟 {name}',
|
||||
'attachments.openFailed': '無法開啟 {name},檔案可能已被移動或刪除。',
|
||||
|
||||
// ─── Status Bar ──────────────────────────────────────
|
||||
'status.connected': '已連線',
|
||||
|
||||
@ -120,6 +120,11 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'openWith.fileType.web': '网页',
|
||||
'openWith.fileType.image': '图片',
|
||||
'openWith.fileType.code': '代码',
|
||||
'openWith.fileType.spreadsheet': '电子表格',
|
||||
'openWith.fileType.presentation': '演示文稿',
|
||||
'openWith.fileType.archive': '压缩包',
|
||||
'openWith.fileType.audio': '音频',
|
||||
'openWith.fileType.video': '视频',
|
||||
'openWith.fileType.file': '文件',
|
||||
|
||||
// ─── Assistant Output Targets ──────────────────────
|
||||
@ -209,6 +214,8 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'workspace.diffReview.diffChanged': '差异已更新。请重新选择行以提交此评论。',
|
||||
'workspace.diffReview.collapsedSelection': '所选行不在收起后的预览中。请重新选择可见行以提交此评论。',
|
||||
'attachments.remove': '移除 {name}',
|
||||
'attachments.open': '打开 {name}',
|
||||
'attachments.openFailed': '无法打开 {name},文件可能已被移动或删除。',
|
||||
|
||||
// ─── Status Bar ──────────────────────────────────────
|
||||
'status.connected': '已连接',
|
||||
|
||||
@ -6,14 +6,26 @@ import type { OpenTarget } from '../stores/openTargetStore'
|
||||
// describeFileType tests
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe('describeFileType', () => {
|
||||
it('markdown → document icon, document categoryKey, uppercased ext', () => {
|
||||
it('markdown → markdown icon, document categoryKey, uppercased ext', () => {
|
||||
expect(describeFileType('a.md')).toEqual({
|
||||
icon: 'description',
|
||||
icon: 'markdown',
|
||||
categoryKey: 'openWith.fileType.document',
|
||||
ext: 'MD',
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['report.pdf', 'picture_as_pdf', 'openWith.fileType.document', 'PDF'],
|
||||
['brief.docx', 'docs', 'openWith.fileType.document', 'DOCX'],
|
||||
['budget.xlsx', 'table_chart', 'openWith.fileType.spreadsheet', 'XLSX'],
|
||||
['launch.pptx', 'slideshow', 'openWith.fileType.presentation', 'PPTX'],
|
||||
['sources.zip', 'folder_zip', 'openWith.fileType.archive', 'ZIP'],
|
||||
['voice.m4a', 'audio_file', 'openWith.fileType.audio', 'M4A'],
|
||||
['demo.mov', 'video_file', 'openWith.fileType.video', 'MOV'],
|
||||
])('classifies %s', (path, icon, categoryKey, ext) => {
|
||||
expect(describeFileType(path)).toEqual({ icon, categoryKey, ext })
|
||||
})
|
||||
|
||||
it('HTML (uppercase path) → web icon, web categoryKey', () => {
|
||||
expect(describeFileType('x.HTML')).toEqual({
|
||||
icon: 'html',
|
||||
@ -45,6 +57,10 @@ describe('describeFileType', () => {
|
||||
ext: 'BIN',
|
||||
})
|
||||
})
|
||||
|
||||
it('extensionless files do not expose the entire filename as an extension', () => {
|
||||
expect(describeFileType('Makefile').ext).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -5,14 +5,26 @@ import type { OpenTarget } from '../stores/openTargetStore'
|
||||
export type FileTypeInfo = { icon: string; categoryKey: string; ext: string }
|
||||
|
||||
const FILE_TYPE_RULES: Array<{ re: RegExp; key: string; icon: string }> = [
|
||||
{ re: /\.(md|markdown|txt|rst)$/i, key: 'document', icon: 'description' },
|
||||
{ re: /\.pdf$/i, key: 'document', icon: 'picture_as_pdf' },
|
||||
{ re: /\.(doc|docx|docm|odt|rtf|pages)$/i, key: 'document', icon: 'docs' },
|
||||
{ re: /\.(md|mdx|markdown)$/i, key: 'document', icon: 'markdown' },
|
||||
{ re: /\.(txt|log|rst)$/i, key: 'document', icon: 'text_snippet' },
|
||||
{ re: /\.(xls|xlsx|xlsm|csv|ods|numbers)$/i, key: 'spreadsheet', icon: 'table_chart' },
|
||||
{ re: /\.(ppt|pptx|pptm|odp|key)$/i, key: 'presentation', icon: 'slideshow' },
|
||||
{ re: /\.(zip|7z|rar|tar|gz|tgz|bz2|xz)$/i, key: 'archive', icon: 'folder_zip' },
|
||||
{ re: /\.(mp3|wav|m4a|flac|aac|ogg|opus)$/i, key: 'audio', icon: 'audio_file' },
|
||||
{ re: /\.(mp4|mov|m4v|webm|mkv|avi)$/i, key: 'video', icon: 'video_file' },
|
||||
{ re: /\.(html?|xhtml)$/i, key: 'web', icon: 'html' },
|
||||
{ re: /\.(png|jpe?g|gif|svg|webp|avif|bmp|ico)$/i, key: 'image', icon: 'image' },
|
||||
{ re: /\.(ts|tsx|js|jsx|mjs|cjs|json|css|scss|less|py|rs|go|java|rb|php|c|cc|cpp|h|hpp|sh|ya?ml|toml)$/i, key: 'code', icon: 'code' },
|
||||
{ re: /\.(ts|tsx|js|jsx|mjs|cjs|json|css|scss|less|py|rs|go|java|rb|php|c|cc|cpp|h|hpp|sh|ya?ml|toml|xml|sql)$/i, key: 'code', icon: 'code' },
|
||||
]
|
||||
|
||||
export function describeFileType(path: string): FileTypeInfo {
|
||||
const ext = (path.split('.').pop() ?? '').toUpperCase()
|
||||
const fileName = path.split(/[\\/]/).pop() ?? path
|
||||
const dotIndex = fileName.lastIndexOf('.')
|
||||
const ext = dotIndex > 0 && dotIndex < fileName.length - 1
|
||||
? fileName.slice(dotIndex + 1).toUpperCase()
|
||||
: ''
|
||||
for (const rule of FILE_TYPE_RULES) {
|
||||
if (rule.re.test(path)) return { icon: rule.icon, categoryKey: `openWith.fileType.${rule.key}`, ext }
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user