Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@
"postprocessing": "^6.37.8",
"react-day-picker": "^9.13.0",
"react-markdown": "^10.1.0",
"react-pdf": "^10.4.1",
"react-resizable-panels": "^3.0.4",
"react-router-dom": "^7.6.0",
"remark-gfm": "^4.0.1",
Expand Down
133 changes: 133 additions & 0 deletions src/components/Folder/PdfViewer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========

import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
import 'react-pdf/dist/Page/AnnotationLayer.css';
import 'react-pdf/dist/Page/TextLayer.css';

// Configure PDF.js worker in the same module where we use Document/Page
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();

const DEFAULT_PAGE_HEIGHT = 1056;

interface LazyPageProps {
pageNumber: number;
width: number | undefined;
}

const LazyPage = memo(function LazyPage({ pageNumber, width }: LazyPageProps) {
const [visible, setVisible] = useState(false);
const ref = useRef<HTMLDivElement>(null);

useEffect(() => {
const node = ref.current;
if (!node) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ rootMargin: '200px' }
);
observer.observe(node);
return () => observer.disconnect();
}, []);

return (
<div ref={ref}>
{visible ? (
<Page
pageNumber={pageNumber}
width={width}
className="mb-4 shadow-md"
/>
) : (
<div
className="bg-background-secondary mb-4"
style={{ height: width ? width * 1.414 : DEFAULT_PAGE_HEIGHT }}
/>
)}
</div>
);
});

interface PdfViewerProps {
/** data URL (data:application/pdf;base64,...) or file path */
content: string;
}

export default function PdfViewer({ content }: PdfViewerProps) {
const [numPages, setNumPages] = useState<number>(0);
const [containerWidth, setContainerWidth] = useState<number>(0);
const [loadError, setLoadError] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);

const onDocumentLoadSuccess = useCallback(
({ numPages }: { numPages: number }) => {
setNumPages(numPages);
setLoadError(null);
},
[]
);

const onDocumentLoadError = useCallback((error: Error) => {
console.error('Failed to load PDF document:', error);
setLoadError(error.message ?? 'Failed to load PDF.');
}, []);

useEffect(() => {
const node = containerRef.current;
if (!node) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
setContainerWidth(entry.contentRect.width);
}
});
observer.observe(node);
return () => observer.disconnect();
}, []);

return (
<div ref={containerRef} className="flex flex-col items-center gap-4">
{loadError ? (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-text-secondary">
<p className="text-sm font-medium">Failed to load PDF</p>
<p className="max-w-xs text-center text-xs text-text-tertiary">
{loadError}
</p>
</div>
) : (
<Document
file={content}
onLoadSuccess={onDocumentLoadSuccess}
onLoadError={onDocumentLoadError}
>
{Array.from({ length: numPages }, (_, index) => (
<LazyPage
key={`page_${index + 1}`}
pageNumber={index + 1}
width={containerWidth || undefined}
/>
))}
</Document>
)}
</div>
);
}
22 changes: 14 additions & 8 deletions src/components/Folder/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@ import {
Search,
SquareTerminal,
} from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { lazy, Suspense, useEffect, useRef, useState } from 'react';
import FolderComponent from './FolderComponent';

const PdfViewer = lazy(() => import('./PdfViewer'));

import { proxyFetchGet } from '@/api/http';
import { MarkDown } from '@/components/ChatBox/MessageItem/MarkDown';
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
Expand Down Expand Up @@ -709,12 +711,12 @@ export default function Folder({ data: _data }: { data?: Agent }) {
className={`flex min-h-0 flex-1 flex-col ${selectedFile?.type === 'html' && !isShowSourceCode ? 'overflow-hidden' : 'scrollbar overflow-y-auto'}`}
>
<div
className={`flex min-h-full flex-col ${selectedFile?.type === 'html' && !isShowSourceCode ? '' : 'p-6'} file-viewer-content`}
className={`flex min-h-full flex-col ${selectedFile?.type === 'html' && !isShowSourceCode ? 'h-full' : ''} ${selectedFile?.type === 'html' && !isShowSourceCode ? '' : 'p-6'} file-viewer-content`}
>
{selectedFile ? (
!loading ? (
selectedFile.type === 'md' && !isShowSourceCode ? (
<div className="prose prose-sm max-w-none">
<div className="prose prose-sm max-w-none overflow-hidden">
<MarkDown
content={selectedFile.content || ''}
enableTypewriter={false}
Expand All @@ -726,11 +728,15 @@ export default function Folder({ data: _data }: { data?: Agent }) {
/>
</div>
) : selectedFile.type === 'pdf' ? (
<iframe
src={selectedFile.content as string}
className="h-full w-full border-0"
title={selectedFile.name}
/>
<Suspense
fallback={
<div className="flex h-full items-center justify-center">
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-b-2 border-blue-600" />
</div>
}
>
<PdfViewer content={selectedFile.content as string} />
</Suspense>
) : ['csv', 'doc', 'docx', 'pptx', 'xlsx'].includes(
selectedFile.type
) ? (
Expand Down
9 changes: 9 additions & 0 deletions test/unit/components/Folder/FileTree.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

// Mock PdfViewer to avoid pdfjs-dist requiring DOMMatrix in jsdom.
// PdfViewer is lazy-loaded (React.lazy) in index.tsx, but vi.mock still
// intercepts the dynamic import so the real pdfjs worker is never loaded
// during unit tests.
vi.mock('../../../../src/components/Folder/PdfViewer', () => ({
default: () => <div data-testid="pdf-viewer-mock" />,
}));

import { FileTree } from '../../../../src/components/Folder/index';

describe('FileTree', () => {
Expand Down
Loading
Loading