From c8fdc51740864eb901e5d4f8d411293d58924840 Mon Sep 17 00:00:00 2001 From: Matt Prusak <10947100+mattprusak@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:56:11 -0500 Subject: [PATCH] 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 --- apps/desktop/electron/main.ts | 5 +- .../src/app/chat/right-rail/preview-file.tsx | 120 +++++++++- .../app/chat/right-rail/preview-pane.test.tsx | 206 +++++++++++++++++- apps/desktop/src/global.d.ts | 2 +- apps/desktop/src/lib/desktop-fs.ts | 4 +- apps/desktop/src/lib/local-preview.test.ts | 29 +++ apps/desktop/src/lib/local-preview.ts | 14 +- .../src/store/preview-persistence.test.ts | 97 +++++++++ apps/desktop/src/store/preview.ts | 57 +++-- 9 files changed, 507 insertions(+), 27 deletions(-) create mode 100644 apps/desktop/src/store/preview-persistence.test.ts diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index a082e2ba60b2d..116c11bfc5330 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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, 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 88f8dc2f5c493..7e2fe83ae74ea 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-file.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-file.tsx @@ -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({ loading: true }) const [forcePreview, setForcePreview] = useState(false) + const [pdfError, setPdfError] = useState() + const [pdfUrl, setPdfUrl] = useState() // User-picked view; null = auto (diff when changed, else rendered markdown, // else source). Reset when the previewed file changes. const [userMode, setUserMode] = useState(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(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 } + if (pdfError) { + return + } + 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 ( +
+