From 45dc96b139a2ac58c52d832746de0f2def68aaa9 Mon Sep 17 00:00:00 2001 From: m4 Date: Thu, 17 Sep 2026 22:57:33 +0800 Subject: [PATCH] feat(desktop): binary refusal page offers the system app first Co-Authored-By: Claude Opus 4.7 --- .../app/chat/right-rail/preview-file.test.tsx | 57 ++++++++++++++++++- .../src/app/chat/right-rail/preview-file.tsx | 11 +++- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/app/chat/right-rail/preview-file.test.tsx b/apps/desktop/src/app/chat/right-rail/preview-file.test.tsx index 8436240460..01564253c3 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-file.test.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-file.test.tsx @@ -1,7 +1,11 @@ -import { cleanup, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { MarkdownPreview } from './preview-file' +const { openFileWithSystemApp } = vi.hoisted(() => ({ openFileWithSystemApp: vi.fn() })) + +vi.mock('@/lib/office-files', () => ({ openFileWithSystemApp })) + +import { LocalFilePreview, MarkdownPreview } from './preview-file' // Behavior tests for the .md file preview renderer: input markdown goes // through normalizeFilePreviewMath -> Streamdown (+ KaTeX math plugin) and must @@ -51,3 +55,50 @@ describe('MarkdownPreview', () => { expect(anchor?.getAttribute('rel')).toBe('noopener noreferrer') }) }) + +const binaryTarget = { + binary: true, + byteSize: 8_000, + kind: 'file' as const, + label: 'report.docx', + language: 'text', + large: false, + path: '/w/report.docx', + previewKind: 'binary' as const, + source: '/w/report.docx', + url: 'file:///w/report.docx' +} + +describe('LocalFilePreview binary refusal', () => { + beforeEach(() => { + openFileWithSystemApp.mockReset().mockResolvedValue(true) + }) + + afterEach(() => { + cleanup() + }) + + it('makes the system app the primary action for binary files', () => { + render() + + const primary = screen.getByRole('button', { name: 'Open with system app' }) + + expect(primary).not.toBeNull() + expect(screen.getByRole('button', { name: 'Preview anyway' })).not.toBeNull() + + fireEvent.click(primary) + expect(openFileWithSystemApp).toHaveBeenCalledWith('/w/report.docx') + }) + + it('keeps Preview anyway primary for merely large text files', () => { + render( + + ) + + expect(screen.getByRole('button', { name: 'Preview anyway' })).not.toBeNull() + expect(screen.queryByRole('button', { name: 'Open with system app' })).toBeNull() + }) +}) diff --git a/apps/desktop/src/app/chat/right-rail/preview-file.tsx b/apps/desktop/src/app/chat/right-rail/preview-file.tsx index a16d64f280..198935ad61 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-file.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-file.tsx @@ -34,6 +34,7 @@ import { createMemoizedMathPlugin } from '@/lib/katex-memo' import { isComposerChord } from '@/lib/keybinds/chords' import { shikiLanguageForFilename } from '@/lib/markdown-code' import { normalizeFilePreviewMath } from '@/lib/markdown-preprocess' +import { openFileWithSystemApp } from '@/lib/office-files' import { cn } from '@/lib/utils' import type { PreviewTarget } from '@/store/preview' import { setPreviewDirty } from '@/store/preview-edit' @@ -1034,11 +1035,19 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar ) { const binary = target.binary || state.binary const size = target.byteSize || state.byteSize + const previewAnyway = () => setForcePreview(true) return ( setForcePreview(true) }} + // Binary files can't render as text — the system default app is the + // useful way out; "Preview anyway" stays as the secondary escape hatch. + primaryAction={ + binary + ? { label: t.preview.openWithSystemApp, onClick: () => void openFileWithSystemApp(filePath) } + : { label: t.preview.previewAnyway, onClick: previewAnyway } + } + secondaryAction={binary ? { label: t.preview.previewAnyway, onClick: previewAnyway } : undefined} title={binary ? t.preview.binaryTitle : t.preview.largeTitle} tone="warning" />