+
+
{attachment.name}{lineLabel}
+
+ {typeLabel}
+
{hasQuotePreview && (
{quotePreview}
)}
+ >
+ )
+
+ if (canOpenLocally) return (
+
+
+
+
+ )
+
+ return (
+
+ {fileVisual}
{onRemove && attachment.id && (
)}
+ {openWith && (
+ setOpenWith(null)}
+ />
+ )}
>
)
}
diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts
index 917b7429..9469c88a 100644
--- a/desktop/src/i18n/locales/en.ts
+++ b/desktop/src/i18n/locales/en.ts
@@ -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',
diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts
index 7c466628..1d4e5ea2 100644
--- a/desktop/src/i18n/locales/jp.ts
+++ b/desktop/src/i18n/locales/jp.ts
@@ -120,6 +120,11 @@ export const jp: Record = {
'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 = {
'workspace.diffReview.diffChanged': '差分が更新されました。このコメントを送信するには行を選択し直してください。',
'workspace.diffReview.collapsedSelection': '選択した行は折りたたまれたプレビューの範囲外です。表示中の行を選択し直してください。',
'attachments.remove': '{name} を削除',
+ 'attachments.open': '{name} を開く',
+ 'attachments.openFailed': '{name} を開けませんでした。ファイルが移動または削除された可能性があります。',
// ─── Status Bar ──────────────────────────────────────
'status.connected': '接続済み',
diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts
index 25836abd..40972689 100644
--- a/desktop/src/i18n/locales/kr.ts
+++ b/desktop/src/i18n/locales/kr.ts
@@ -120,6 +120,11 @@ export const kr: Record = {
'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 = {
'workspace.diffReview.diffChanged': 'Diff가 변경되었습니다. 이 댓글을 제출하려면 줄을 다시 선택하세요.',
'workspace.diffReview.collapsedSelection': '선택한 줄이 접힌 미리보기 범위 밖에 있습니다. 표시된 줄을 다시 선택하세요.',
'attachments.remove': '{name} 제거',
+ 'attachments.open': '{name} 열기',
+ 'attachments.openFailed': '{name}을(를) 열 수 없습니다. 파일이 이동되었거나 삭제되었을 수 있습니다.',
// ─── Status Bar ──────────────────────────────────────
'status.connected': '연결됨',
diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts
index 4c7ca334..ef14406f 100644
--- a/desktop/src/i18n/locales/zh-TW.ts
+++ b/desktop/src/i18n/locales/zh-TW.ts
@@ -120,6 +120,11 @@ export const zh: Record = {
'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 = {
'workspace.diffReview.diffChanged': '差異已更新。請重新選擇行以提交此評論。',
'workspace.diffReview.collapsedSelection': '所選行不在收合後的預覽中。請重新選擇可見行以提交此評論。',
'attachments.remove': '移除 {name}',
+ 'attachments.open': '開啟 {name}',
+ 'attachments.openFailed': '無法開啟 {name},檔案可能已被移動或刪除。',
// ─── Status Bar ──────────────────────────────────────
'status.connected': '已連線',
diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts
index 211d8573..67f538ac 100644
--- a/desktop/src/i18n/locales/zh.ts
+++ b/desktop/src/i18n/locales/zh.ts
@@ -120,6 +120,11 @@ export const zh: Record = {
'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 = {
'workspace.diffReview.diffChanged': '差异已更新。请重新选择行以提交此评论。',
'workspace.diffReview.collapsedSelection': '所选行不在收起后的预览中。请重新选择可见行以提交此评论。',
'attachments.remove': '移除 {name}',
+ 'attachments.open': '打开 {name}',
+ 'attachments.openFailed': '无法打开 {name},文件可能已被移动或删除。',
// ─── Status Bar ──────────────────────────────────────
'status.connected': '已连接',
diff --git a/desktop/src/lib/openWithItems.test.ts b/desktop/src/lib/openWithItems.test.ts
index 0811419a..02737dbb 100644
--- a/desktop/src/lib/openWithItems.test.ts
+++ b/desktop/src/lib/openWithItems.test.ts
@@ -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('')
+ })
})
// ──────────────────────────────────────────────────────────────────────────────
diff --git a/desktop/src/lib/openWithItems.ts b/desktop/src/lib/openWithItems.ts
index d4dc5a29..b8c64ba1 100644
--- a/desktop/src/lib/openWithItems.ts
+++ b/desktop/src/lib/openWithItems.ts
@@ -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 }
}