feat(desktop): add local attachment open actions

This commit is contained in:
程序员阿江(Relakkes) 2026-07-16 02:49:08 +08:00
parent 80d4f1186c
commit d9e30cebcc
9 changed files with 363 additions and 23 deletions

View File

@ -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()

View File

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

View File

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

View File

@ -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': '接続済み',

View File

@ -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': '연결됨',

View File

@ -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': '已連線',

View File

@ -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': '已连接',

View File

@ -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('')
})
})
// ──────────────────────────────────────────────────────────────────────────────

View File

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