feat(desktop): binary refusal page offers the system app first

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-09-17 22:57:33 +08:00
parent a1f9e97782
commit 45dc96b139
2 changed files with 64 additions and 4 deletions
@@ -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(<LocalFilePreview reloadKey={0} target={binaryTarget} />)
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(
<LocalFilePreview
reloadKey={0}
target={{ ...binaryTarget, binary: false, large: true, label: 'huge.log', path: '/w/huge.log' }}
/>
)
expect(screen.getByRole('button', { name: 'Preview anyway' })).not.toBeNull()
expect(screen.queryByRole('button', { name: 'Open with system app' })).toBeNull()
})
})
@@ -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 (
<PreviewEmptyState
body={binary ? t.preview.binaryBody(target.label) : t.preview.largeBody(target.label, formatBytes(size))}
primaryAction={{ label: t.preview.previewAnyway, onClick: () => 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"
/>