Add inline PDF file preview

This commit is contained in:
Bohdan Triapitsyn
2026-06-12 20:08:19 +03:00
parent 33522a0d58
commit 1a623f9b1c
3 changed files with 127 additions and 55 deletions
+121 -55
View File
@@ -42,7 +42,7 @@ import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { useFileSearchStore } from '@/stores/useFileSearchStore'; import { useFileSearchStore } from '@/stores/useFileSearchStore';
import { useDeviceInfo } from '@/lib/device'; import { useDeviceInfo } from '@/lib/device';
import { cn, getModifierLabel, getRevealLabelKey, hasModifier } from '@/lib/utils'; import { cn, getModifierLabel, getRevealLabelKey, hasModifier } from '@/lib/utils';
import { getLanguageFromExtension, getImageMimeType, isDrawioFile, isImageFile } from '@/lib/toolHelpers'; import { getLanguageFromExtension, getImageMimeType, isDrawioFile, isImageFile, isPdfFile } from '@/lib/toolHelpers';
import { getRuntimeUrlResolver } from '@/lib/runtime-url'; import { getRuntimeUrlResolver } from '@/lib/runtime-url';
import { refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth'; import { refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch'; import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
@@ -823,9 +823,10 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const [fileContent, setFileContent] = React.useState<string>(''); const [fileContent, setFileContent] = React.useState<string>('');
const { isPlaying: isTTSPlaying, play: playTTS, stop: stopTTS } = useMessageTTS(); const { isPlaying: isTTSPlaying, play: playTTS, stop: stopTTS } = useMessageTTS();
const [fileLoading, setFileLoading] = React.useState(false); const [fileLoading, setFileLoading] = React.useState(false);
const [fileError, setFileError] = React.useState<string | null>(null); const [fileError, setFileError] = React.useState<string | null>(null);
const [desktopImageSrc, setDesktopImageSrc] = React.useState<string>(''); const [desktopImageSrc, setDesktopImageSrc] = React.useState<string>('');
const [imageAssetAuthReadyKey, setImageAssetAuthReadyKey] = React.useState(''); const [imageAssetAuthReadyKey, setImageAssetAuthReadyKey] = React.useState('');
const [pdfAssetAuthReadyKey, setPdfAssetAuthReadyKey] = React.useState('');
const [loadedFilePath, setLoadedFilePath] = React.useState<string | null>(null); const [loadedFilePath, setLoadedFilePath] = React.useState<string | null>(null);
@@ -1693,8 +1694,9 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
setDesktopImageSrc(''); setDesktopImageSrc('');
setLoadedFilePath(null); setLoadedFilePath(null);
const selectedIsImage = isImageFile(node.path); const selectedIsImage = isImageFile(node.path);
const isSvg = node.path.toLowerCase().endsWith('.svg'); const isSvg = node.path.toLowerCase().endsWith('.svg');
const selectedIsPdf = isPdfFile(node.path);
if (isMobile) { if (isMobile) {
setShowMobilePageContent(true); setShowMobilePageContent(true);
@@ -1709,13 +1711,21 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
} }
// Web: binary images should not be read as utf8. // Web: binary images should not be read as utf8.
if (!runtime.isDesktop && selectedIsImage && !isSvg) { if (!runtime.isDesktop && selectedIsImage && !isSvg) {
setFileContent(''); setFileContent('');
setDraftContent(''); setDraftContent('');
setLoadedFilePath(node.path); setLoadedFilePath(node.path);
setFileLoading(false); setFileLoading(false);
return; return;
} }
if (selectedIsPdf) {
setFileContent('');
setDraftContent('');
setLoadedFilePath(node.path);
setFileLoading(false);
return;
}
setFileLoading(true); setFileLoading(true);
@@ -2189,8 +2199,9 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
}); });
} }
const isSelectedImage = Boolean(selectedFile?.path && isImageFile(selectedFile.path)); const isSelectedImage = Boolean(selectedFile?.path && isImageFile(selectedFile.path));
const isSelectedSvg = Boolean(selectedFile?.path && selectedFile.path.toLowerCase().endsWith('.svg')); const isSelectedSvg = Boolean(selectedFile?.path && selectedFile.path.toLowerCase().endsWith('.svg'));
const isSelectedPdf = Boolean(selectedFile?.path && isPdfFile(selectedFile.path));
const pendingNavigationTargetPath = React.useMemo( const pendingNavigationTargetPath = React.useMemo(
() => normalizePath(pendingFileNavigation?.path ?? ''), () => normalizePath(pendingFileNavigation?.path ?? ''),
[pendingFileNavigation?.path], [pendingFileNavigation?.path],
@@ -2200,23 +2211,24 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
&& pendingNavigationTargetPath && pendingNavigationTargetPath
&& selectedFilePath && selectedFilePath
&& selectedFilePath === pendingNavigationTargetPath && selectedFilePath === pendingNavigationTargetPath
&& !fileLoading && !fileLoading
&& !fileError && !fileError
&& !isSelectedImage, && !isSelectedImage
); && !isSelectedPdf,
);
const displaySelectedPath = React.useMemo(() => { const displaySelectedPath = React.useMemo(() => {
return getDisplayPath(root, selectedFilePath); return getDisplayPath(root, selectedFilePath);
}, [selectedFilePath, root]); }, [selectedFilePath, root]);
const canCopy = Boolean(selectedFile && (!isSelectedImage || isSelectedSvg) && fileContent.length > 0); const canCopy = Boolean(selectedFile && (!isSelectedImage || isSelectedSvg) && !isSelectedPdf && fileContent.length > 0);
const canCopyPath = Boolean(selectedFile && displaySelectedPath.length > 0); const canCopyPath = Boolean(selectedFile && displaySelectedPath.length > 0);
const canEdit = Boolean(selectedFile && !selectedFileIsOutsideWorkspace && !isSelectedImage && files.writeFile && fileContent.length <= MAX_VIEW_CHARS); const canEdit = Boolean(selectedFile && !selectedFileIsOutsideWorkspace && !isSelectedImage && !isSelectedPdf && files.writeFile && fileContent.length <= MAX_VIEW_CHARS);
const isMarkdown = Boolean(selectedFile?.path && isMarkdownFile(selectedFile.path)); const isMarkdown = Boolean(selectedFile?.path && isMarkdownFile(selectedFile.path));
const isJson = Boolean(selectedFile?.path && isJsonFile(selectedFile.path)); const isJson = Boolean(selectedFile?.path && isJsonFile(selectedFile.path));
const isHtml = Boolean(selectedFile?.path && isHtmlFile(selectedFile.path)); const isHtml = Boolean(selectedFile?.path && isHtmlFile(selectedFile.path));
const isDrawio = Boolean(selectedFile?.path && isDrawioFile(selectedFile.path)); const isDrawio = Boolean(selectedFile?.path && isDrawioFile(selectedFile.path));
const isTextFile = Boolean(selectedFile && !isSelectedImage); const isTextFile = Boolean(selectedFile && !isSelectedImage && !isSelectedPdf);
const canUseShikiFileView = isTextFile && !isMarkdown && !isDrawio && !(isHtml && htmlViewMode === 'preview'); const canUseShikiFileView = isTextFile && !isMarkdown && !isDrawio && !(isHtml && htmlViewMode === 'preview');
const staticLanguageExtension = React.useMemo( const staticLanguageExtension = React.useMemo(
() => (selectedFilePath ? languageByExtension(selectedFilePath) : null), () => (selectedFilePath ? languageByExtension(selectedFilePath) : null),
@@ -2554,7 +2566,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
return; return;
} }
if (fileError || isSelectedImage) { if (fileError || isSelectedImage || isSelectedPdf) {
setPendingFileNavigation(null); setPendingFileNavigation(null);
pendingNavigationCycleRef.current = { key: '', attempts: 0 }; pendingNavigationCycleRef.current = { key: '', attempts: 0 };
return; return;
@@ -2623,9 +2635,10 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
draftContent, draftContent,
editorViewReadyNonce, editorViewReadyNonce,
fileError, fileError,
fileLoading, fileLoading,
isSelectedImage, isSelectedImage,
loadedFilePath, isSelectedPdf,
loadedFilePath,
handleSelectFile, handleSelectFile,
pendingFileNavigation, pendingFileNavigation,
root, root,
@@ -2654,7 +2667,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
return; return;
} }
if (fileLoading || loadedFilePath !== targetPath || fileError || isSelectedImage) { if (fileLoading || loadedFilePath !== targetPath || fileError || isSelectedImage || isSelectedPdf) {
return; return;
} }
@@ -2672,9 +2685,10 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
confirmDiscardOpen, confirmDiscardOpen,
fileError, fileError,
fileLoading, fileLoading,
handleSelectFile, handleSelectFile,
isSelectedImage, isSelectedImage,
loadedFilePath, isSelectedPdf,
loadedFilePath,
pendingFileFocusPath, pendingFileFocusPath,
root, root,
selectedFile?.path, selectedFile?.path,
@@ -2813,6 +2827,10 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const imageAssetAuthKey = selectedFile?.path && isSelectedImage && !runtime.isDesktop && !isSelectedSvg const imageAssetAuthKey = selectedFile?.path && isSelectedImage && !runtime.isDesktop && !isSelectedSvg
? `${selectedFile.path}|${selectedFileReadOptions.allowOutsideWorkspace ? 'outside' : 'workspace'}|${selectedFileReadOptions.outsideFileGrant ?? ''}` ? `${selectedFile.path}|${selectedFileReadOptions.allowOutsideWorkspace ? 'outside' : 'workspace'}|${selectedFileReadOptions.outsideFileGrant ?? ''}`
: ''; : '';
const pdfAssetAuthKey = selectedFile?.path && isSelectedPdf
? `${selectedFile.path}|${selectedFileReadOptions.allowOutsideWorkspace ? 'outside' : 'workspace'}|${selectedFileReadOptions.outsideFileGrant ?? ''}`
: '';
React.useEffect(() => { React.useEffect(() => {
if (!imageAssetAuthKey) { if (!imageAssetAuthKey) {
@@ -2833,9 +2851,35 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
}; };
}, [imageAssetAuthKey]); }, [imageAssetAuthKey]);
const isImageAssetAuthLoading = Boolean(imageAssetAuthKey && imageAssetAuthReadyKey !== imageAssetAuthKey); const isImageAssetAuthLoading = Boolean(imageAssetAuthKey && imageAssetAuthReadyKey !== imageAssetAuthKey);
React.useEffect(() => {
if (!pdfAssetAuthKey) {
setPdfAssetAuthReadyKey('');
return;
}
let cancelled = false;
setPdfAssetAuthReadyKey('');
void refreshRuntimeUrlAuthToken(getRuntimeApiBaseUrl())
.then((token) => {
if (!cancelled && token) setPdfAssetAuthReadyKey(pdfAssetAuthKey);
})
.catch((error) => {
if (!cancelled) {
setFileError(error instanceof Error ? error.message : t('filesView.error.readFileFailed'));
setPdfAssetAuthReadyKey(pdfAssetAuthKey);
}
});
return () => {
cancelled = true;
};
}, [pdfAssetAuthKey, t]);
const isPdfAssetAuthLoading = Boolean(pdfAssetAuthKey && pdfAssetAuthReadyKey !== pdfAssetAuthKey);
const imageSrc = selectedFile?.path && isSelectedImage const imageSrc = selectedFile?.path && isSelectedImage
? (runtime.isDesktop ? (runtime.isDesktop
? (isSelectedSvg ? (isSelectedSvg
? `data:${getImageMimeType(selectedFile.path)};utf8,${encodeURIComponent(fileContent)}` ? `data:${getImageMimeType(selectedFile.path)};utf8,${encodeURIComponent(fileContent)}`
@@ -2847,7 +2891,25 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
allowOutsideWorkspace: selectedFileReadOptions.allowOutsideWorkspace ? 'true' : undefined, allowOutsideWorkspace: selectedFileReadOptions.allowOutsideWorkspace ? 'true' : undefined,
outsideFileGrant: selectedFileReadOptions.outsideFileGrant, outsideFileGrant: selectedFileReadOptions.outsideFileGrant,
}) : '')) }) : ''))
: ''; : '';
const pdfSrc = selectedFile?.path && isSelectedPdf && pdfAssetAuthReadyKey === pdfAssetAuthKey
? getRuntimeUrlResolver().authenticatedAsset('/api/fs/raw', {
path: selectedFile.path,
allowOutsideWorkspace: selectedFileReadOptions.allowOutsideWorkspace ? 'true' : undefined,
outsideFileGrant: selectedFileReadOptions.outsideFileGrant,
})
: '';
const renderPdfPreview = React.useCallback((file: FileNode) => (
<div className="h-full overflow-hidden bg-[var(--surface-background)]">
<iframe
src={pdfSrc}
className="h-full w-full border-0"
title={file.name}
/>
</div>
), [pdfSrc]);
React.useEffect(() => { React.useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -3045,7 +3107,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{!isSelectedImage && ( {!isSelectedImage && !isSelectedPdf && (
<> <>
{withTooltip(wrapLines ? t('filesView.editor.disableLineWrap') : t('filesView.editor.enableLineWrap'), {withTooltip(wrapLines ? t('filesView.editor.disableLineWrap') : t('filesView.editor.enableLineWrap'),
<Button <Button
@@ -3537,7 +3599,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
<ScrollableOverlay outerClassName="h-full min-w-0" className="h-full min-w-0"> <ScrollableOverlay outerClassName="h-full min-w-0" className="h-full min-w-0">
{!selectedFile ? ( {!selectedFile ? (
<div className="p-3 typography-ui text-muted-foreground">{t('filesView.editor.pickFileFromTree')}</div> <div className="p-3 typography-ui text-muted-foreground">{t('filesView.editor.pickFileFromTree')}</div>
) : (fileLoading || isImageAssetAuthLoading) ? ( ) : (fileLoading || isImageAssetAuthLoading || isPdfAssetAuthLoading) ? (
suppressFileLoadingIndicator suppressFileLoadingIndicator
? <div className="p-3" /> ? <div className="p-3" />
: ( : (
@@ -3548,15 +3610,17 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
) )
) : fileError ? ( ) : fileError ? (
<div className="p-3 typography-ui text-[color:var(--status-error)]">{fileError}</div> <div className="p-3 typography-ui text-[color:var(--status-error)]">{fileError}</div>
) : isSelectedImage ? ( ) : isSelectedImage ? (
<div className="flex h-full items-center justify-center p-3"> <div className="flex h-full items-center justify-center p-3">
<img <img
src={imageSrc} src={imageSrc}
alt={selectedFile?.name ?? t('filesView.editor.imageAltFallback')} alt={selectedFile?.name ?? t('filesView.editor.imageAltFallback')}
className="max-w-full max-h-[70vh] object-contain rounded-md border border-border/30 bg-primary/10" className="max-w-full max-h-[70vh] object-contain rounded-md border border-border/30 bg-primary/10"
/> />
</div> </div>
) : selectedFile && isDrawio && drawioViewMode === 'preview' ? ( ) : isSelectedPdf ? (
renderPdfPreview(selectedFile)
) : selectedFile && isDrawio && drawioViewMode === 'preview' ? (
<div className="h-full overflow-hidden" style={{ minHeight: '400px' }}> <div className="h-full overflow-hidden" style={{ minHeight: '400px' }}>
<DiagramEditor <DiagramEditor
key={`${selectedFile.path}:${drawioRemountNonce}`} key={`${selectedFile.path}:${drawioRemountNonce}`}
@@ -3881,7 +3945,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
{renderFloatingFileControls({ exitFullscreenOnly: true })} {renderFloatingFileControls({ exitFullscreenOnly: true })}
</div> </div>
<ScrollableOverlay outerClassName="h-full min-w-0" className="h-full min-w-0"> <ScrollableOverlay outerClassName="h-full min-w-0" className="h-full min-w-0">
{(fileLoading || isImageAssetAuthLoading) ? ( {(fileLoading || isImageAssetAuthLoading || isPdfAssetAuthLoading) ? (
suppressFileLoadingIndicator suppressFileLoadingIndicator
? <div className="p-4" /> ? <div className="p-4" />
: ( : (
@@ -3892,15 +3956,17 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
) )
) : fileError ? ( ) : fileError ? (
<div className="p-4 typography-ui text-[color:var(--status-error)]">{fileError}</div> <div className="p-4 typography-ui text-[color:var(--status-error)]">{fileError}</div>
) : isSelectedImage ? ( ) : isSelectedImage ? (
<div className="flex h-full items-center justify-center p-4"> <div className="flex h-full items-center justify-center p-4">
<img <img
src={imageSrc} src={imageSrc}
alt={selectedFile.name} alt={selectedFile.name}
className="max-w-full max-h-full object-contain rounded-md border border-border/30 bg-primary/10" className="max-w-full max-h-full object-contain rounded-md border border-border/30 bg-primary/10"
/> />
</div> </div>
) : isMarkdown && getMdViewMode() === 'preview' ? ( ) : isSelectedPdf ? (
renderPdfPreview(selectedFile)
) : isMarkdown && getMdViewMode() === 'preview' ? (
<div className="h-full overflow-auto p-4"> <div className="h-full overflow-auto p-4">
{fileContent.length > 500 * 1024 && ( {fileContent.length > 500 * 1024 && (
<div className="mb-3 rounded-md border border-status-warning/20 bg-status-warning/10 px-3 py-2 text-sm text-status-warning"> <div className="mb-3 rounded-md border border-status-warning/20 bg-status-warning/10 px-3 py-2 text-sm text-status-warning">
+5
View File
@@ -693,6 +693,11 @@ export function isImageFile(filePath: string): boolean {
return IMAGE_EXTENSIONS.includes(ext || ''); return IMAGE_EXTENSIONS.includes(ext || '');
} }
export function isPdfFile(filePath: string): boolean {
const ext = filePath.split('.').pop()?.toLowerCase();
return ext === 'pdf';
}
export function getImageMimeType(filePath: string): string { export function getImageMimeType(filePath: string): string {
const ext = filePath.split('.').pop()?.toLowerCase(); const ext = filePath.split('.').pop()?.toLowerCase();
const mimeMap: Record<string, string> = { const mimeMap: Record<string, string> = {
+1
View File
@@ -840,6 +840,7 @@ export const registerFsRoutes = (app, dependencies) => {
'.ico': 'image/x-icon', '.ico': 'image/x-icon',
'.bmp': 'image/bmp', '.bmp': 'image/bmp',
'.avif': 'image/avif', '.avif': 'image/avif',
'.pdf': 'application/pdf',
}; };
const mimeType = mimeMap[ext] || 'application/octet-stream'; const mimeType = mimeMap[ext] || 'application/octet-stream';