fix(desktop): render remote PDFs in preview rail
PDFs were classified as generic binary/text previews, rendering raw %PDF bytes locally and failing entirely for remote-only files. Classify PDFs as their own preview kind, load bytes through the existing local/remote filesystem bridge, convert them to revocable Blob URLs for Chromium's embedded viewer, migrate persisted pre-PDF tabs at restore, and retry restored previews when the active filesystem connection changes. Salvaged from #76008-era base onto current main: PDF classification now composes with the remote-HTML enrichment branch, and the persisted-tab migration runs before the One-Browser URL rekey in decodePreviewTabs. Supersedes #76565. Co-authored-by: Brooklyn Nicholson <brooklyn@brooklyn.sh>
This commit is contained in:
parent
c8648278c3
commit
c8fdc51740
|
|
@ -862,6 +862,7 @@ const MEDIA_MIME_TYPES = {
|
|||
'.mp4': 'video/mp4',
|
||||
'.ogg': 'audio/ogg',
|
||||
'.opus': 'audio/ogg; codecs=opus',
|
||||
'.pdf': 'application/pdf',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.wav': 'audio/wav',
|
||||
|
|
@ -870,6 +871,7 @@ const MEDIA_MIME_TYPES = {
|
|||
}
|
||||
|
||||
const PREVIEW_HTML_EXTENSIONS = new Set(['.html', '.htm'])
|
||||
const PREVIEW_PDF_EXTENSIONS = new Set(['.pdf'])
|
||||
const PREVIEW_WATCH_DEBOUNCE_MS = 120
|
||||
const LOCAL_PREVIEW_HOSTS = new Set(['0.0.0.0', '127.0.0.1', '::1', '[::1]', 'localhost'])
|
||||
const TEXT_PREVIEW_MAX_BYTES = 512 * 1024
|
||||
|
|
@ -4904,7 +4906,8 @@ async function previewFileTarget(rawTarget, baseDir) {
|
|||
const metadata = previewFileMetadata(resolved, mimeType)
|
||||
const isHtml = PREVIEW_HTML_EXTENSIONS.has(ext)
|
||||
const isImage = mimeType.startsWith('image/')
|
||||
const previewKind = isHtml ? 'html' : isImage ? 'image' : metadata.binary ? 'binary' : 'text'
|
||||
const isPdf = PREVIEW_PDF_EXTENSIONS.has(ext) || mimeType === 'application/pdf'
|
||||
const previewKind = isHtml ? 'html' : isImage ? 'image' : isPdf ? 'pdf' : metadata.binary ? 'binary' : 'text'
|
||||
|
||||
return {
|
||||
binary: metadata.binary,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import type * as React from 'react'
|
||||
import type {
|
||||
ComponentProps,
|
||||
|
|
@ -23,6 +24,7 @@ import { Tip } from '@/components/ui/tooltip'
|
|||
import { translateNow, useI18n } from '@/i18n'
|
||||
import {
|
||||
desktopFileDiff,
|
||||
desktopFsCacheKey,
|
||||
desktopGitRoot,
|
||||
readDesktopFileDataUrl,
|
||||
readDesktopFileText,
|
||||
|
|
@ -33,7 +35,7 @@ import { shikiLanguageForFilename } from '@/lib/markdown-code'
|
|||
import { cn } from '@/lib/utils'
|
||||
import type { PreviewTarget } from '@/store/preview'
|
||||
import { setPreviewDirty } from '@/store/preview-edit'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
import { $connection, $currentCwd } from '@/store/session'
|
||||
import { notifyWorkspaceChanged } from '@/store/workspace-events'
|
||||
|
||||
const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const
|
||||
|
|
@ -217,6 +219,45 @@ function looksBinaryBytes(bytes: Uint8Array) {
|
|||
return suspicious / Math.min(bytes.length, 4096) > 0.12
|
||||
}
|
||||
|
||||
function dataUrlToBlob(dataUrl: string) {
|
||||
const comma = dataUrl.indexOf(',')
|
||||
|
||||
if (comma < 0 || !dataUrl.startsWith('data:')) {
|
||||
throw new Error('Invalid PDF data URL')
|
||||
}
|
||||
|
||||
const metadata = dataUrl
|
||||
.slice(5, comma)
|
||||
.split(';')
|
||||
.map(part => part.trim().toLowerCase())
|
||||
|
||||
const payload = dataUrl.slice(comma + 1)
|
||||
|
||||
if (metadata[0] !== 'application/pdf' || !metadata.slice(1).includes('base64')) {
|
||||
throw new Error('Invalid PDF data URL type')
|
||||
}
|
||||
|
||||
let binary: string
|
||||
|
||||
try {
|
||||
binary = atob(decodeURIComponent(payload))
|
||||
} catch {
|
||||
throw new Error('Invalid PDF data URL payload')
|
||||
}
|
||||
|
||||
if (!binary.startsWith('%PDF-')) {
|
||||
throw new Error('Invalid PDF file header')
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index)
|
||||
}
|
||||
|
||||
return new Blob([bytes], { type: 'application/pdf' })
|
||||
}
|
||||
|
||||
async function readTextPreview(filePath: string) {
|
||||
try {
|
||||
return await readDesktopFileText(filePath)
|
||||
|
|
@ -579,6 +620,8 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
|||
const { t } = useI18n()
|
||||
const [state, setState] = useState<LocalPreviewState>({ loading: true })
|
||||
const [forcePreview, setForcePreview] = useState(false)
|
||||
const [pdfError, setPdfError] = useState<string>()
|
||||
const [pdfUrl, setPdfUrl] = useState<string>()
|
||||
// User-picked view; null = auto (diff when changed, else rendered markdown,
|
||||
// else source). Reset when the previewed file changes.
|
||||
const [userMode, setUserMode] = useState<null | PreviewViewMode>(null)
|
||||
|
|
@ -600,8 +643,11 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
|||
// hover flag (no state — only the keydown handler reads it).
|
||||
const readViewRef = useRef<HTMLDivElement>(null)
|
||||
const hoverRef = useRef(false)
|
||||
const connection = useStore($connection)
|
||||
const fsCacheKey = desktopFsCacheKey(connection)
|
||||
const filePath = filePathForTarget(target)
|
||||
const isImage = target.previewKind === 'image'
|
||||
const isPdf = target.previewKind === 'pdf'
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
|
||||
useEffect(() => {
|
||||
|
|
@ -620,7 +666,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
|||
// when the file is forcibly previewed past the binary refusal screen.
|
||||
const isText = target.previewKind === 'text' || target.previewKind === 'binary' || target.previewKind === 'html'
|
||||
|
||||
const blockedByTarget = !isImage && !forcePreview && (target.binary || target.large)
|
||||
const blockedByTarget = !isImage && !isPdf && !forcePreview && (target.binary || target.large)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
|
@ -632,7 +678,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
|||
return
|
||||
}
|
||||
|
||||
if (!isImage && !isText) {
|
||||
if (!isImage && !isPdf && !isText) {
|
||||
setState({ loading: false })
|
||||
|
||||
return
|
||||
|
|
@ -641,7 +687,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
|||
setState({ loading: true })
|
||||
|
||||
try {
|
||||
if (isImage) {
|
||||
if (isImage || isPdf) {
|
||||
// Prefer bytes the caller already handed us (a pasted/dropped
|
||||
// screenshot) over re-reading a path that may be transient/unreadable.
|
||||
const dataUrl = target.dataUrl || (await readDesktopFileDataUrl(filePath))
|
||||
|
|
@ -698,7 +744,49 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
|||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, selfReload, target.dataUrl, target.language])
|
||||
}, [
|
||||
blockedByTarget,
|
||||
filePath,
|
||||
forcePreview,
|
||||
fsCacheKey,
|
||||
isImage,
|
||||
isPdf,
|
||||
isText,
|
||||
reloadKey,
|
||||
selfReload,
|
||||
target.dataUrl,
|
||||
target.language
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
setPdfUrl(undefined)
|
||||
setPdfError(undefined)
|
||||
|
||||
if (!isPdf || !state.dataUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
// Chromium's PDF viewer is blank for large data: URLs in an iframe. Use a
|
||||
// blob URL instead, and revoke it when the target or loaded bytes change.
|
||||
if (typeof URL.createObjectURL !== 'function') {
|
||||
setPdfError('PDF preview requires object URL support')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let objectUrl: string
|
||||
|
||||
try {
|
||||
objectUrl = URL.createObjectURL(dataUrlToBlob(state.dataUrl))
|
||||
setPdfUrl(objectUrl)
|
||||
} catch (error) {
|
||||
setPdfError(error instanceof Error ? error.message : String(error))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
return () => URL.revokeObjectURL(objectUrl)
|
||||
}, [isPdf, state.dataUrl])
|
||||
|
||||
// Editing is only offered for whole, readable text — never images, binaries,
|
||||
// or files we only loaded the first 512 KB of (saving would drop the tail).
|
||||
|
|
@ -889,8 +977,13 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
|||
return <PreviewEmptyState body={state.error} title={t.preview.unavailable} />
|
||||
}
|
||||
|
||||
if (pdfError) {
|
||||
return <PreviewEmptyState body={pdfError} title={t.preview.unavailable} />
|
||||
}
|
||||
|
||||
if (
|
||||
!isImage &&
|
||||
!isPdf &&
|
||||
!forcePreview &&
|
||||
(target.binary || target.large || state.binary || (state.byteSize ?? 0) > TEXT_PREVIEW_MAX_BYTES)
|
||||
) {
|
||||
|
|
@ -920,6 +1013,23 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
|||
)
|
||||
}
|
||||
|
||||
if (isPdf && state.dataUrl && pdfUrl) {
|
||||
return (
|
||||
<div className="h-full w-full overflow-hidden bg-transparent">
|
||||
<iframe
|
||||
aria-label={target.label}
|
||||
className="h-full w-full border-0 bg-white"
|
||||
src={pdfUrl}
|
||||
title={target.label}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isPdf && state.dataUrl) {
|
||||
return <PageLoader label={t.preview.loading} />
|
||||
}
|
||||
|
||||
if (isText && state.text !== undefined) {
|
||||
const isMarkdown = (state.language || target.language) === 'markdown'
|
||||
const hasDiff = Boolean(state.diff && state.diff.trim())
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $connection } from '@/store/session'
|
||||
|
|
@ -6,6 +6,23 @@ import { $connection } from '@/store/session'
|
|||
import { PreviewPane } from './preview-pane'
|
||||
import { forgetPreviewStripTools, previewConsoleState } from './preview-strip-tools'
|
||||
|
||||
function stubPdfObjectUrls() {
|
||||
const NativeUrl = URL
|
||||
let objectUrlIndex = 0
|
||||
const createObjectURL = vi.fn((_blob: Blob) => `blob:pdf-preview-${(objectUrlIndex += 1)}`)
|
||||
const revokeObjectURL = vi.fn()
|
||||
|
||||
class TestUrl extends NativeUrl {}
|
||||
|
||||
Object.defineProperties(TestUrl, {
|
||||
createObjectURL: { configurable: true, value: createObjectURL },
|
||||
revokeObjectURL: { configurable: true, value: revokeObjectURL }
|
||||
})
|
||||
vi.stubGlobal('URL', TestUrl)
|
||||
|
||||
return { createObjectURL, revokeObjectURL }
|
||||
}
|
||||
|
||||
describe('PreviewPane console state', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
|
|
@ -133,4 +150,191 @@ describe('PreviewPane console state', () => {
|
|||
expect(sourceLink?.getAttribute('target')).toBeNull()
|
||||
expect(fireEvent.click(sourceLink!)).toBe(false)
|
||||
})
|
||||
|
||||
it('renders PDF targets in an embedded viewer', async () => {
|
||||
const dataUrl = 'data:application/pdf;base64,JVBERi0xLjQ='
|
||||
const readFileDataUrl = vi.fn(async () => dataUrl)
|
||||
const { createObjectURL, revokeObjectURL } = stubPdfObjectUrls()
|
||||
$connection.set({ mode: 'local' } as never)
|
||||
vi.stubGlobal('window', {
|
||||
...window,
|
||||
hermesDesktop: {
|
||||
readFileDataUrl
|
||||
}
|
||||
})
|
||||
|
||||
let rendered!: ReturnType<typeof render>
|
||||
await act(async () => {
|
||||
rendered = render(
|
||||
<PreviewPane
|
||||
target={{
|
||||
kind: 'file',
|
||||
label: 'spec.pdf',
|
||||
path: '/tmp/spec.pdf',
|
||||
previewKind: 'pdf',
|
||||
source: '/tmp/spec.pdf',
|
||||
url: 'file:///tmp/spec.pdf'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
await waitFor(
|
||||
() => expect(rendered.container.querySelector('iframe')).not.toBeNull(),
|
||||
{ container: rendered.container }
|
||||
)
|
||||
expect(rendered.container.querySelector('iframe')?.getAttribute('src')).toBe('blob:pdf-preview-1')
|
||||
expect(readFileDataUrl).toHaveBeenCalledWith('/tmp/spec.pdf')
|
||||
const blob = createObjectURL.mock.calls[0]?.[0]
|
||||
|
||||
expect(blob).toBeInstanceOf(Blob)
|
||||
expect(blob?.type).toBe('application/pdf')
|
||||
expect(await blob?.text()).toBe('%PDF-1.4')
|
||||
|
||||
await act(async () => {
|
||||
rendered.rerender(
|
||||
<PreviewPane
|
||||
target={{
|
||||
kind: 'file',
|
||||
label: 'other.pdf',
|
||||
path: '/tmp/other.pdf',
|
||||
previewKind: 'pdf',
|
||||
source: '/tmp/other.pdf',
|
||||
url: 'file:///tmp/other.pdf'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
await waitFor(() => expect(createObjectURL).toHaveBeenCalledTimes(2), {
|
||||
container: rendered.container
|
||||
})
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:pdf-preview-1')
|
||||
expect(rendered.container.querySelector('iframe')?.getAttribute('src')).toBe('blob:pdf-preview-2')
|
||||
|
||||
rendered.unmount()
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:pdf-preview-2')
|
||||
})
|
||||
|
||||
it('accepts case-insensitive metadata and percent-escaped base64', async () => {
|
||||
const readFileDataUrl = vi.fn(async () => 'data:APPLICATION/PDF;BASE64,%4AVBERi0xLjQ=')
|
||||
const { createObjectURL } = stubPdfObjectUrls()
|
||||
$connection.set({ mode: 'local' } as never)
|
||||
vi.stubGlobal('window', {
|
||||
...window,
|
||||
hermesDesktop: {
|
||||
readFileDataUrl
|
||||
}
|
||||
})
|
||||
|
||||
let rendered!: ReturnType<typeof render>
|
||||
await act(async () => {
|
||||
rendered = render(
|
||||
<PreviewPane
|
||||
target={{
|
||||
kind: 'file',
|
||||
label: 'spec.pdf',
|
||||
path: '/tmp/spec.pdf',
|
||||
previewKind: 'pdf',
|
||||
source: '/tmp/spec.pdf',
|
||||
url: 'file:///tmp/spec.pdf'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
await waitFor(() => expect(createObjectURL).toHaveBeenCalledTimes(1), {
|
||||
container: rendered.container
|
||||
})
|
||||
expect(rendered.container.querySelector('iframe')?.getAttribute('src')).toBe('blob:pdf-preview-1')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a non-PDF MIME type', 'data:text/html;base64,JVBERi0xLjQ=', 'Invalid PDF data URL type'],
|
||||
['bytes without a PDF header', 'data:application/pdf;base64,PGh0bWw+', 'Invalid PDF file header'],
|
||||
['a malformed payload', 'data:application/pdf;base64,%', 'Invalid PDF data URL payload']
|
||||
])('rejects %s before creating an object URL', async (_case, dataUrl, expectedError) => {
|
||||
const readFileDataUrl = vi.fn(async () => dataUrl)
|
||||
const { createObjectURL } = stubPdfObjectUrls()
|
||||
$connection.set({ mode: 'local' } as never)
|
||||
vi.stubGlobal('window', {
|
||||
...window,
|
||||
hermesDesktop: {
|
||||
readFileDataUrl
|
||||
}
|
||||
})
|
||||
|
||||
let rendered!: ReturnType<typeof render>
|
||||
await act(async () => {
|
||||
rendered = render(
|
||||
<PreviewPane
|
||||
target={{
|
||||
kind: 'file',
|
||||
label: 'spec.pdf',
|
||||
path: '/tmp/spec.pdf',
|
||||
previewKind: 'pdf',
|
||||
source: '/tmp/spec.pdf',
|
||||
url: 'file:///tmp/spec.pdf'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
await waitFor(() => expect(rendered.container.textContent).toContain(expectedError), {
|
||||
container: rendered.container
|
||||
})
|
||||
expect(rendered.container.querySelector('iframe')).toBeNull()
|
||||
expect(createObjectURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retries a restored PDF when the filesystem connection becomes remote', async () => {
|
||||
const filePath = '/remote/spec.pdf'
|
||||
const dataUrl = 'data:application/pdf;base64,JVBERi0xLjQ='
|
||||
stubPdfObjectUrls()
|
||||
|
||||
const readFileDataUrl = vi.fn(async () => {
|
||||
throw new Error('File preview failed: file does not exist')
|
||||
})
|
||||
|
||||
const api = vi.fn(async () => dataUrl)
|
||||
$connection.set({ mode: 'local' } as never)
|
||||
vi.stubGlobal('window', {
|
||||
...window,
|
||||
hermesDesktop: {
|
||||
api,
|
||||
readFileDataUrl
|
||||
}
|
||||
})
|
||||
|
||||
let rendered!: ReturnType<typeof render>
|
||||
await act(async () => {
|
||||
rendered = render(
|
||||
<PreviewPane
|
||||
target={{
|
||||
kind: 'file',
|
||||
label: 'spec.pdf',
|
||||
path: filePath,
|
||||
previewKind: 'pdf',
|
||||
source: filePath,
|
||||
url: `file://${filePath}`
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
await waitFor(() => expect(readFileDataUrl).toHaveBeenCalledTimes(1), { container: rendered.container })
|
||||
|
||||
await act(async () => {
|
||||
$connection.set({ baseUrl: 'http://macmini', mode: 'remote', profile: 'macmini' } as never)
|
||||
})
|
||||
|
||||
await waitFor(
|
||||
() => expect(rendered.container.querySelector('iframe')).not.toBeNull(),
|
||||
{ container: rendered.container }
|
||||
)
|
||||
expect(api).toHaveBeenCalledWith({
|
||||
path: `/api/fs/read-data-url?path=${encodeURIComponent(filePath)}`,
|
||||
profile: 'macmini'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -790,7 +790,7 @@ export interface HermesPreviewTarget {
|
|||
language?: string
|
||||
mimeType?: string
|
||||
path?: string
|
||||
previewKind?: 'binary' | 'html' | 'image' | 'text'
|
||||
previewKind?: 'binary' | 'html' | 'image' | 'pdf' | 'text'
|
||||
renderMode?: 'preview' | 'source'
|
||||
source: string
|
||||
url: string
|
||||
|
|
|
|||
|
|
@ -29,8 +29,8 @@ function connectionCacheKey(connection: HermesConnection | null) {
|
|||
return `${connection.mode || 'local'}:${connection.remoteKind || ''}:${connection.profile || ''}:${target}`
|
||||
}
|
||||
|
||||
export function desktopFsCacheKey() {
|
||||
return connectionCacheKey($connection.get())
|
||||
export function desktopFsCacheKey(connection: HermesConnection | null = $connection.get()) {
|
||||
return connectionCacheKey(connection)
|
||||
}
|
||||
|
||||
export function isDesktopFsRemoteMode() {
|
||||
|
|
|
|||
|
|
@ -168,3 +168,32 @@ describe('remote HTML previews', () => {
|
|||
expect(openPreviewInBrowser).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PDF previews', () => {
|
||||
it('classifies PDF files as PDF previews', () => {
|
||||
expect(localPreviewTarget('/tmp/spec.pdf')).toMatchObject({
|
||||
path: '/tmp/spec.pdf',
|
||||
previewKind: 'pdf'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps ordinary text files on the source-preview path', () => {
|
||||
expect(localPreviewTarget('/tmp/spec.md')).toMatchObject({
|
||||
language: 'markdown',
|
||||
previewKind: 'text'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not UTF-8-enrich remote PDFs before loading their bytes', async () => {
|
||||
vi.clearAllMocks()
|
||||
window.hermesDesktop = {
|
||||
normalizePreviewTarget: vi.fn(async () => null)
|
||||
} as never
|
||||
|
||||
await expect(normalizeOrLocalPreviewTarget('/remote/spec.pdf')).resolves.toMatchObject({
|
||||
path: '/remote/spec.pdf',
|
||||
previewKind: 'pdf'
|
||||
})
|
||||
expect(readDesktopFileDataUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { PreviewTarget } from '@/store/preview'
|
|||
|
||||
const HTML_EXTENSIONS = new Set(['.htm', '.html'])
|
||||
const IMAGE_EXTENSIONS = new Set(['.bmp', '.gif', '.jpeg', '.jpg', '.png', '.svg', '.webp'])
|
||||
const PDF_EXTENSIONS = new Set(['.pdf'])
|
||||
// Mirrors `_FS_DATA_URL_MAX_BYTES` in the backend filesystem endpoint.
|
||||
const REMOTE_HTML_PREVIEW_MAX_BYTES = 16 * 1024 * 1024
|
||||
const REMOTE_HTML_PREVIEW_MAX_BASE64_BYTES = Math.ceil(REMOTE_HTML_PREVIEW_MAX_BYTES / 3) * 4
|
||||
|
|
@ -203,6 +204,7 @@ export function localPreviewTarget(rawTarget: string, cwd?: string | null): Prev
|
|||
const ext = extension(path)
|
||||
const isHtml = HTML_EXTENSIONS.has(ext)
|
||||
const isImage = IMAGE_EXTENSIONS.has(ext)
|
||||
const isPdf = PDF_EXTENSIONS.has(ext)
|
||||
|
||||
return {
|
||||
kind: 'file',
|
||||
|
|
@ -210,16 +212,22 @@ export function localPreviewTarget(rawTarget: string, cwd?: string | null): Prev
|
|||
language: LANGUAGE_BY_EXT[ext] || 'text',
|
||||
path,
|
||||
// Renderer fallback can't stat/sniff without reading; assume text unless
|
||||
// image/html extension says otherwise. LocalFilePreview still guards
|
||||
// image/html/pdf extension says otherwise. LocalFilePreview still guards
|
||||
// binary/large files when readFileText/readFileDataUrl returns metadata.
|
||||
previewKind: isHtml ? 'html' : isImage ? 'image' : 'text',
|
||||
previewKind: isHtml ? 'html' : isImage ? 'image' : isPdf ? 'pdf' : 'text',
|
||||
source: raw,
|
||||
url: pathToFileUrl(path)
|
||||
}
|
||||
}
|
||||
|
||||
async function enrichPreviewTarget(target: PreviewTarget | null): Promise<PreviewTarget | null> {
|
||||
if (!isDesktopFsRemoteMode() || !target || target.kind !== 'file' || target.previewKind === 'image') {
|
||||
if (
|
||||
!isDesktopFsRemoteMode() ||
|
||||
!target ||
|
||||
target.kind !== 'file' ||
|
||||
target.previewKind === 'image' ||
|
||||
target.previewKind === 'pdf'
|
||||
) {
|
||||
return target
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { decodePreviewTabs } from './preview'
|
||||
|
||||
describe('persisted preview migration', () => {
|
||||
it('upgrades a pre-PDF remote tab from binary to pdf', () => {
|
||||
const source = '/remote/.hermes/desktop-attachments/spec.pdf'
|
||||
|
||||
const [restored] = decodePreviewTabs(
|
||||
JSON.stringify([
|
||||
{
|
||||
id: `file:file://${source}`,
|
||||
target: {
|
||||
binary: true,
|
||||
kind: 'file',
|
||||
label: 'spec.pdf',
|
||||
large: true,
|
||||
path: source,
|
||||
previewKind: 'binary',
|
||||
source,
|
||||
url: `file://${source}`
|
||||
}
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
expect(restored?.target.previewKind).toBe('pdf')
|
||||
})
|
||||
|
||||
it('leaves a persisted non-PDF binary tab unchanged', () => {
|
||||
const source = '/work/archive.zip'
|
||||
|
||||
const [restored] = decodePreviewTabs(
|
||||
JSON.stringify([
|
||||
{
|
||||
id: `file:file://${source}`,
|
||||
target: {
|
||||
binary: true,
|
||||
kind: 'file',
|
||||
label: 'archive.zip',
|
||||
path: source,
|
||||
previewKind: 'binary',
|
||||
source,
|
||||
url: `file://${source}`
|
||||
}
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
expect(restored?.target.previewKind).toBe('binary')
|
||||
})
|
||||
|
||||
it.each(['report.pdf#notes', 'report.pdf?draft'])('treats %s as a literal filesystem path', sourceName => {
|
||||
const source = `/work/${sourceName}`
|
||||
|
||||
const [restored] = decodePreviewTabs(
|
||||
JSON.stringify([
|
||||
{
|
||||
id: `file:file://${encodeURI(source)}`,
|
||||
target: {
|
||||
binary: true,
|
||||
kind: 'file',
|
||||
label: sourceName,
|
||||
path: source,
|
||||
previewKind: 'binary',
|
||||
source,
|
||||
url: `file:///work/${encodeURIComponent(sourceName)}`
|
||||
}
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
expect(restored?.target.previewKind).toBe('binary')
|
||||
})
|
||||
|
||||
it('does not overwrite a non-binary PDF preview kind', () => {
|
||||
const source = '/work/spec.pdf'
|
||||
|
||||
const [restored] = decodePreviewTabs(
|
||||
JSON.stringify([
|
||||
{
|
||||
id: `file:file://${source}`,
|
||||
target: {
|
||||
kind: 'file',
|
||||
label: 'spec.pdf',
|
||||
path: source,
|
||||
previewKind: 'text',
|
||||
source,
|
||||
url: `file://${source}`
|
||||
}
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
expect(restored?.target.previewKind).toBe('text')
|
||||
})
|
||||
})
|
||||
|
|
@ -35,7 +35,7 @@ export interface PreviewTarget {
|
|||
language?: string
|
||||
mimeType?: string
|
||||
path?: string
|
||||
previewKind?: 'binary' | 'html' | 'image' | 'text'
|
||||
previewKind?: 'binary' | 'html' | 'image' | 'pdf' | 'text'
|
||||
renderMode?: 'preview' | 'source'
|
||||
source: string
|
||||
/** Runtime-only target that cannot be restored from persisted state. */
|
||||
|
|
@ -92,20 +92,49 @@ function isPreviewTab(value: unknown): value is PreviewTab {
|
|||
return typeof r.id === 'string' && (r.id.startsWith('file:') || r.id.startsWith('url:')) && isPreviewTarget(r.target)
|
||||
}
|
||||
|
||||
function isPdfFileTarget(target: PreviewTarget): boolean {
|
||||
if (target.kind !== 'file') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (target.mimeType?.toLowerCase() === 'application/pdf') {
|
||||
return true
|
||||
}
|
||||
|
||||
if ([target.path, target.source].some(value => (value ? /\.pdf$/i.test(value) : false))) {
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
return /\.pdf$/i.test(new URL(target.url).pathname)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Upgrade tabs persisted by builds that classified PDFs as generic binary.
|
||||
* Without this restore-time migration, an already-open PDF keeps taking the
|
||||
* obsolete raw-binary path after Desktop itself has been upgraded. */
|
||||
export function decodePreviewTabs(raw: string): PreviewTab[] {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
const tabs = (Array.isArray(parsed) ? parsed.filter(isPreviewTab) : []).map(tab =>
|
||||
isPdfFileTarget(tab.target) && tab.target.previewKind === 'binary'
|
||||
? { ...tab, target: { ...tab.target, previewKind: 'pdf' as const } }
|
||||
: tab
|
||||
)
|
||||
|
||||
// One Browser: rekey restored URL tabs onto the singleton id (rows written
|
||||
// before the id existed carried one id per address) and keep only the
|
||||
// LAST — the most recently opened page is the one the browser shows.
|
||||
const lastUrl = tabs.findLast(tab => tab.target.kind === 'url')
|
||||
|
||||
return tabs
|
||||
.filter(tab => tab.target.kind !== 'url' || tab === lastUrl)
|
||||
.map(tab => (tab.target.kind === 'url' ? { ...tab, id: previewTabId(tab.target) } : tab))
|
||||
}
|
||||
|
||||
export const $previewTabs = persistentAtom<PreviewTab[]>(TABS_STORAGE_KEY, [], {
|
||||
decode: raw => {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
const tabs = Array.isArray(parsed) ? parsed.filter(isPreviewTab) : []
|
||||
|
||||
// One Browser: rekey restored URL tabs onto the singleton id (rows written
|
||||
// before the id existed carried one id per address) and keep only the
|
||||
// LAST — the most recently opened page is the one the browser shows.
|
||||
const lastUrl = tabs.findLast(tab => tab.target.kind === 'url')
|
||||
|
||||
return tabs
|
||||
.filter(tab => tab.target.kind !== 'url' || tab === lastUrl)
|
||||
.map(tab => (tab.target.kind === 'url' ? { ...tab, id: previewTabId(tab.target) } : tab))
|
||||
},
|
||||
decode: decodePreviewTabs,
|
||||
// Inline bytes are not restorable. Strip them from images, and skip remote
|
||||
// HTML and artifact tabs that cannot render without their in-memory payload.
|
||||
encode: tabs =>
|
||||
|
|
|
|||
Loading…
Reference in New Issue