mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-09-04 19:25:43 +00:00
feat(preview): Implement object preview functionality (#94)
* feat(preview): implement object preview functionality with token-based access * docs: update README to include inline object preview feature * test: add range read error handling and attachment content disposition tests
This commit is contained in:
@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { IconTile } from '@/components/ui/icon-tile';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { ObjectPreview } from '@/components/buckets/ObjectPreview';
|
||||
import { ArrowLeft, ChevronRight, Copy, Download, File, Loader2, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { downloadObject, formatBytes } from '@/lib/file-utils';
|
||||
@@ -42,6 +43,7 @@ export function ObjectDetailsView() {
|
||||
const bucket = buckets.find((b) => b.name === bucketName);
|
||||
const canBucket = useBucketCan();
|
||||
const canDelete = canBucket(bucket, 'object.delete');
|
||||
const canRead = canBucket(bucket, 'object.read');
|
||||
|
||||
const [metadata, setMetadata] = useState<ObjectMetadata | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -216,9 +218,19 @@ export function ObjectDetailsView() {
|
||||
|
||||
{/* Preview */}
|
||||
<CardSection title="Preview">
|
||||
<div className="px-5 py-10 text-center text-[13px] text-[var(--muted-foreground)]">
|
||||
No preview available for this object.
|
||||
</div>
|
||||
{canRead && bucketName && objectKey ? (
|
||||
<ObjectPreview
|
||||
bucket={bucketName}
|
||||
objectKey={objectKey}
|
||||
size={metadata.size}
|
||||
contentType={metadata.contentType}
|
||||
onDownload={handleDownload}
|
||||
/>
|
||||
) : (
|
||||
<div className="px-5 py-10 text-center text-[13px] text-[var(--muted-foreground)]">
|
||||
No preview available for this object.
|
||||
</div>
|
||||
)}
|
||||
</CardSection>
|
||||
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ObjectPreviewState } from '@/hooks/useObjectPreview';
|
||||
import { useObjectPreview } from '@/hooks/useObjectPreview';
|
||||
import { TEXT_HIGHLIGHT_MAX_BYTES } from '@/lib/preview-utils';
|
||||
import { ObjectPreview } from './ObjectPreview';
|
||||
|
||||
vi.mock('@/hooks/useObjectPreview', () => ({ useObjectPreview: vi.fn() }));
|
||||
|
||||
const mockedHook = vi.mocked(useObjectPreview);
|
||||
|
||||
function state(overrides: Partial<ObjectPreviewState>): ObjectPreviewState {
|
||||
return {
|
||||
kind: 'none',
|
||||
status: 'unsupported',
|
||||
objectUrl: null,
|
||||
text: null,
|
||||
mediaUrl: null,
|
||||
retry: vi.fn(),
|
||||
onMediaError: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderPreview() {
|
||||
// A .json key keeps the highlight language deterministic in the text test.
|
||||
return render(
|
||||
<ObjectPreview bucket="b" objectKey="k.json" size={100} contentType="text/plain" onDownload={vi.fn()} />,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('ObjectPreview', () => {
|
||||
it('shows the loading state', () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'image', status: 'loading' }));
|
||||
renderPreview();
|
||||
expect(screen.getByText(/loading preview/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders an image from the object url', () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'image', status: 'ready', objectUrl: 'blob:img' }));
|
||||
renderPreview();
|
||||
expect(screen.getByRole('img')).toHaveAttribute('src', 'blob:img');
|
||||
});
|
||||
|
||||
it('renders video with the media url', () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'video', status: 'ready', mediaUrl: '/u?pt=t' }));
|
||||
const { container } = renderPreview();
|
||||
const video = container.querySelector('video');
|
||||
expect(video).not.toBeNull();
|
||||
expect(video).toHaveAttribute('src', '/u?pt=t');
|
||||
expect(video).not.toHaveAttribute('autoplay');
|
||||
});
|
||||
|
||||
it('renders audio with the media url', () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'audio', status: 'ready', mediaUrl: '/u?pt=t' }));
|
||||
const { container } = renderPreview();
|
||||
expect(container.querySelector('audio')).toHaveAttribute('src', '/u?pt=t');
|
||||
});
|
||||
|
||||
it('renders pdf in an iframe', () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'pdf', status: 'ready', objectUrl: 'blob:pdf' }));
|
||||
renderPreview();
|
||||
expect(screen.getByTitle('k.json')).toHaveAttribute('src', 'blob:pdf');
|
||||
});
|
||||
|
||||
it('renders plain text immediately and highlighted text after the import resolves', async () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'text', status: 'ready', text: '{"a": 1}' }));
|
||||
const { container } = renderPreview();
|
||||
expect(container.querySelector('pre')).toHaveTextContent('{"a": 1}');
|
||||
await waitFor(() => expect(container.querySelector('code [class*="hljs-"]')).not.toBeNull());
|
||||
});
|
||||
|
||||
it('shows the too-large notice with a download action', () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'text', status: 'too-large' }));
|
||||
renderPreview();
|
||||
expect(screen.getByText(/too large to preview/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /download/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the unsupported notice', () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'none', status: 'unsupported' }));
|
||||
renderPreview();
|
||||
expect(screen.getByText(/no preview available/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the binary notice', () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'text', status: 'binary' }));
|
||||
renderPreview();
|
||||
expect(screen.getByText(/doesn't appear to be text/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the error state with retry wired to the hook', () => {
|
||||
const retry = vi.fn();
|
||||
mockedHook.mockReturnValue(state({ kind: 'image', status: 'error', retry }));
|
||||
renderPreview();
|
||||
fireEvent.click(screen.getByRole('button', { name: /retry/i }));
|
||||
expect(retry).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The tests below are additional to the brief's list. They were added to
|
||||
// close coverage gaps (media error and resume handling, the highlight size
|
||||
// guard, and the defensive fallback branch) found while verifying the
|
||||
// 90 percent coverage requirement on this file.
|
||||
|
||||
it('skips highlighting for text over the highlight size limit', () => {
|
||||
const bigText = 'a'.repeat(TEXT_HIGHLIGHT_MAX_BYTES + 1);
|
||||
mockedHook.mockReturnValue(state({ kind: 'text', status: 'ready', text: bigText }));
|
||||
const { container } = renderPreview();
|
||||
expect(container.querySelector('code [class*="hljs-"]')).toBeNull();
|
||||
expect(container.querySelector('code')?.textContent).toHaveLength(bigText.length);
|
||||
});
|
||||
|
||||
it('unmounting before the highlight import resolves is safe', async () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'text', status: 'ready', text: '{"a": 1}' }));
|
||||
const { container, unmount } = renderPreview();
|
||||
expect(() => unmount()).not.toThrow();
|
||||
expect(container.innerHTML).toBe('');
|
||||
// Let the pending dynamic import resolve after unmount. The cancelled
|
||||
// guard means the resolved callback renders nothing back into the
|
||||
// detached container, so it stays empty and no error is thrown.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('resumes the playback position after a media error and reload', () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'video', status: 'ready', mediaUrl: '/u?pt=t' }));
|
||||
const { container } = renderPreview();
|
||||
const video = container.querySelector('video')!;
|
||||
|
||||
// Loading metadata with no prior error is a no-op (resume position is 0).
|
||||
fireEvent.loadedMetadata(video);
|
||||
|
||||
Object.defineProperty(video, 'currentTime', { value: 42, writable: true, configurable: true });
|
||||
fireEvent.error(video);
|
||||
Object.defineProperty(video, 'currentTime', { value: 0, writable: true, configurable: true });
|
||||
fireEvent.loadedMetadata(video);
|
||||
expect(video.currentTime).toBe(42);
|
||||
});
|
||||
|
||||
it('does not restore a position when the media element reported no currentTime', () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'audio', status: 'ready', mediaUrl: '/u?pt=t' }));
|
||||
const { container } = renderPreview();
|
||||
const audio = container.querySelector('audio')!;
|
||||
|
||||
// The element reports no currentTime, so the captured resume position
|
||||
// falls back to 0 via the ?? 0 guard, which keeps the restore guard
|
||||
// (resumeAtRef.current > 0) false. A later loadedmetadata must then leave
|
||||
// the position untouched, unlike the sibling resume test above.
|
||||
Object.defineProperty(audio, 'currentTime', { value: undefined, writable: true, configurable: true });
|
||||
fireEvent.error(audio);
|
||||
Object.defineProperty(audio, 'currentTime', { value: 99, writable: true, configurable: true });
|
||||
fireEvent.loadedMetadata(audio);
|
||||
expect(audio.currentTime).toBe(99);
|
||||
});
|
||||
|
||||
it('falls back to the generic notice for an unhandled preview kind', () => {
|
||||
mockedHook.mockReturnValue(state({ kind: 'none', status: 'ready' }));
|
||||
renderPreview();
|
||||
expect(screen.getByText(/no preview available/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Download, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useObjectPreview } from '@/hooks/useObjectPreview';
|
||||
import { getHighlightLanguage, TEXT_HIGHLIGHT_MAX_BYTES } from '@/lib/preview-utils';
|
||||
import { formatBytes } from '@/lib/file-utils';
|
||||
|
||||
function Notice({
|
||||
message,
|
||||
onDownload,
|
||||
onRetry,
|
||||
}: {
|
||||
message: string;
|
||||
onDownload?: () => void;
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 px-5 py-10 text-center text-[13px] text-[var(--muted-foreground)]">
|
||||
<p>{message}</p>
|
||||
{(onRetry || onDownload) && (
|
||||
<div className="flex gap-2">
|
||||
{onRetry && (
|
||||
<Button variant="secondary" onClick={onRetry}>
|
||||
<RefreshCw className="h-4 w-4" /> Retry
|
||||
</Button>
|
||||
)}
|
||||
{onDownload && (
|
||||
<Button variant="secondary" onClick={onDownload}>
|
||||
<Download className="h-4 w-4" /> Download
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlock({ text, objectKey }: { text: string; objectKey: string }) {
|
||||
const [html, setHtml] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Highlighting is progressive enhancement. Large files and any import or
|
||||
// highlight failure fall back to the plain text already on screen.
|
||||
if (text.length > TEXT_HIGHLIGHT_MAX_BYTES) return;
|
||||
let cancelled = false;
|
||||
import('@/lib/highlight')
|
||||
.then(({ highlight }) => {
|
||||
if (!cancelled) setHtml(highlight(text, getHighlightLanguage(objectKey)));
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [text, objectKey]);
|
||||
|
||||
return (
|
||||
<pre className="overflow-x-auto px-5 py-4 font-mono text-[12.5px] leading-relaxed">
|
||||
{html !== null ? <code dangerouslySetInnerHTML={{ __html: html }} /> : <code>{text}</code>}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
export function ObjectPreview({
|
||||
bucket,
|
||||
objectKey,
|
||||
size,
|
||||
contentType,
|
||||
onDownload,
|
||||
}: {
|
||||
bucket: string;
|
||||
objectKey: string;
|
||||
size: number;
|
||||
contentType?: string;
|
||||
onDownload: () => void;
|
||||
}) {
|
||||
const preview = useObjectPreview(bucket, objectKey, size, contentType);
|
||||
const mediaRef = useRef<HTMLVideoElement | HTMLAudioElement | null>(null);
|
||||
const resumeAtRef = useRef(0);
|
||||
|
||||
const handleMediaError = () => {
|
||||
resumeAtRef.current = mediaRef.current?.currentTime ?? 0;
|
||||
preview.onMediaError();
|
||||
};
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
if (resumeAtRef.current > 0 && mediaRef.current) {
|
||||
mediaRef.current.currentTime = resumeAtRef.current;
|
||||
resumeAtRef.current = 0;
|
||||
}
|
||||
};
|
||||
|
||||
switch (preview.status) {
|
||||
case 'unsupported':
|
||||
return <Notice message="No preview available for this object." onDownload={onDownload} />;
|
||||
case 'too-large':
|
||||
return (
|
||||
<Notice
|
||||
message={`File is too large to preview (${formatBytes(size)}), download it instead.`}
|
||||
onDownload={onDownload}
|
||||
/>
|
||||
);
|
||||
case 'binary':
|
||||
return <Notice message="This file doesn't appear to be text." onDownload={onDownload} />;
|
||||
case 'error':
|
||||
return <Notice message="Could not load the preview." onRetry={preview.retry} onDownload={onDownload} />;
|
||||
case 'loading':
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 px-5 py-10 text-[13px] text-[var(--muted-foreground)]">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Loading preview…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
switch (preview.kind) {
|
||||
case 'image':
|
||||
return (
|
||||
<div className="flex justify-center bg-[var(--surface-sunken)] px-5 py-6">
|
||||
<img src={preview.objectUrl!} alt={objectKey} className="h-auto max-w-full object-contain" />
|
||||
</div>
|
||||
);
|
||||
case 'video':
|
||||
return (
|
||||
<div className="flex justify-center bg-black">
|
||||
<video
|
||||
ref={(el) => {
|
||||
mediaRef.current = el;
|
||||
}}
|
||||
controls
|
||||
preload="metadata"
|
||||
src={preview.mediaUrl!}
|
||||
onError={handleMediaError}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
className="max-h-[85vh] w-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case 'audio':
|
||||
return (
|
||||
<div className="px-5 py-6">
|
||||
<audio
|
||||
ref={(el) => {
|
||||
mediaRef.current = el;
|
||||
}}
|
||||
controls
|
||||
src={preview.mediaUrl!}
|
||||
onError={handleMediaError}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case 'pdf':
|
||||
return <iframe src={preview.objectUrl!} title={objectKey} className="h-[85vh] w-full" />;
|
||||
case 'text':
|
||||
return <CodeBlock text={preview.text!} objectKey={objectKey} />;
|
||||
default:
|
||||
return <Notice message="No preview available for this object." onDownload={onDownload} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { objectsApi } from '@/lib/api';
|
||||
import { useObjectPreview } from './useObjectPreview';
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
objectsApi: {
|
||||
get: vi.fn(),
|
||||
getPreviewUrl: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedGet = vi.mocked(objectsApi.get);
|
||||
const mockedGetPreviewUrl = vi.mocked(objectsApi.getPreviewUrl);
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.URL.createObjectURL = vi.fn(() => 'blob:mock-url');
|
||||
globalThis.URL.revokeObjectURL = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// One client per test, created outside the component so rerenders reuse it.
|
||||
function createWrapper() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
};
|
||||
}
|
||||
|
||||
describe('useObjectPreview', () => {
|
||||
it('fetches a blob and produces an object url for images', async () => {
|
||||
mockedGet.mockResolvedValue(new Blob([new Uint8Array([1, 2, 3])]));
|
||||
const { result } = renderHook(() => useObjectPreview('b', 'pic.png', 100, 'image/png'), { wrapper: createWrapper() });
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
expect(result.current.kind).toBe('image');
|
||||
expect(result.current.objectUrl).toBe('blob:mock-url');
|
||||
expect(mockedGet).toHaveBeenCalledWith('b', 'pic.png');
|
||||
});
|
||||
|
||||
it('decodes text content', async () => {
|
||||
mockedGet.mockResolvedValue(new Blob(['{"a": 1}']));
|
||||
const { result } = renderHook(() => useObjectPreview('b', 'data.json', 8, 'application/json'), { wrapper: createWrapper() });
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
expect(result.current.text).toBe('{"a": 1}');
|
||||
});
|
||||
|
||||
it('reports binary content pretending to be text', async () => {
|
||||
mockedGet.mockResolvedValue(new Blob([new Uint8Array([0, 1, 2, 3, 4, 0, 1, 2, 3, 4])]));
|
||||
const { result } = renderHook(() => useObjectPreview('b', 'weird.log', 10, undefined), { wrapper: createWrapper() });
|
||||
await waitFor(() => expect(result.current.status).toBe('binary'));
|
||||
});
|
||||
|
||||
it('does not fetch when the object is over the limit', () => {
|
||||
const { result } = renderHook(
|
||||
() => useObjectPreview('b', 'big.txt', 6 * 1024 * 1024, 'text/plain'),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
expect(result.current.status).toBe('too-large');
|
||||
expect(mockedGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports unsupported kinds without fetching', () => {
|
||||
const { result } = renderHook(() => useObjectPreview('b', 'blob.bin', 10, undefined), { wrapper: createWrapper() });
|
||||
expect(result.current.status).toBe('unsupported');
|
||||
expect(mockedGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('mints a media url for video without fetching bytes', async () => {
|
||||
mockedGetPreviewUrl.mockResolvedValue({ url: '/api/v1/buckets/b/objects/v.mp4?pt=tok', expiresAt: 'later' });
|
||||
const { result } = renderHook(() => useObjectPreview('b', 'v.mp4', 10_000_000_000, 'video/mp4'), { wrapper: createWrapper() });
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
expect(result.current.mediaUrl).toBe('/api/v1/buckets/b/objects/v.mp4?pt=tok');
|
||||
expect(mockedGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-mints once on media error, then reports error', async () => {
|
||||
mockedGetPreviewUrl.mockResolvedValue({ url: '/u?pt=1', expiresAt: 'later' });
|
||||
const { result } = renderHook(() => useObjectPreview('b', 'v.mp4', 10, 'video/mp4'), { wrapper: createWrapper() });
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
|
||||
mockedGetPreviewUrl.mockResolvedValue({ url: '/u?pt=2', expiresAt: 'later' });
|
||||
act(() => result.current.onMediaError());
|
||||
await waitFor(() => expect(result.current.mediaUrl).toBe('/u?pt=2'));
|
||||
expect(result.current.status).toBe('ready');
|
||||
|
||||
act(() => result.current.onMediaError());
|
||||
await waitFor(() => expect(result.current.status).toBe('error'));
|
||||
});
|
||||
|
||||
it('reports fetch failures and recovers on retry', async () => {
|
||||
mockedGet.mockRejectedValueOnce(new Error('network down'));
|
||||
const { result } = renderHook(() => useObjectPreview('b', 'pic.png', 100, 'image/png'), { wrapper: createWrapper() });
|
||||
await waitFor(() => expect(result.current.status).toBe('error'));
|
||||
|
||||
mockedGet.mockResolvedValue(new Blob([new Uint8Array([1])]));
|
||||
act(() => result.current.retry());
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
});
|
||||
|
||||
it('revokes the object url on unmount', async () => {
|
||||
mockedGet.mockResolvedValue(new Blob([new Uint8Array([1])]));
|
||||
const { result, unmount } = renderHook(() => useObjectPreview('b', 'pic.png', 100, 'image/png'), { wrapper: createWrapper() });
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
unmount();
|
||||
expect(globalThis.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
|
||||
});
|
||||
|
||||
it('recovers when the target object changes after a media error', async () => {
|
||||
mockedGetPreviewUrl.mockResolvedValue({ url: '/a?pt=1', expiresAt: 'later' });
|
||||
const { result, rerender } = renderHook(
|
||||
({ k }) => useObjectPreview('b', k, 10, 'video/mp4'),
|
||||
{ wrapper: createWrapper(), initialProps: { k: 'a.mp4' } },
|
||||
);
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
|
||||
// Exhaust the single re-mint on object A, driving it into the error state.
|
||||
act(() => result.current.onMediaError());
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
act(() => result.current.onMediaError());
|
||||
await waitFor(() => expect(result.current.status).toBe('error'));
|
||||
|
||||
// Switch to object B on the same hook instance. The stale error from A
|
||||
// must not leave B stuck: it should load and reach ready.
|
||||
mockedGetPreviewUrl.mockResolvedValue({ url: '/b?pt=1', expiresAt: 'later' });
|
||||
rerender({ k: 'b.mp4' });
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
expect(result.current.mediaUrl).toBe('/b?pt=1');
|
||||
});
|
||||
|
||||
it('refetches the media url on retry', async () => {
|
||||
mockedGetPreviewUrl.mockResolvedValue({ url: '/u?pt=1', expiresAt: 'later' });
|
||||
const { result } = renderHook(() => useObjectPreview('b', 'v.mp4', 10, 'video/mp4'), { wrapper: createWrapper() });
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
|
||||
mockedGetPreviewUrl.mockResolvedValue({ url: '/u?pt=2', expiresAt: 'later' });
|
||||
act(() => result.current.retry());
|
||||
await waitFor(() => expect(result.current.mediaUrl).toBe('/u?pt=2'));
|
||||
expect(result.current.status).toBe('ready');
|
||||
});
|
||||
|
||||
it('ignores a pending text decode after unmount', async () => {
|
||||
let resolveText: (value: string) => void = () => {};
|
||||
const blob = new Blob(['hello']);
|
||||
vi.spyOn(blob, 'text').mockReturnValue(new Promise<string>((res) => { resolveText = res; }));
|
||||
mockedGet.mockResolvedValue(blob);
|
||||
const { result, unmount } = renderHook(() => useObjectPreview('b', 'a.txt', 5, 'text/plain'), { wrapper: createWrapper() });
|
||||
await waitFor(() => expect(result.current.objectUrl).toBe('blob:mock-url'));
|
||||
|
||||
// Unmount before the decode resolves, then resolve it. The cancelled
|
||||
// guard must swallow the late result rather than set state.
|
||||
unmount();
|
||||
act(() => resolveText('hello'));
|
||||
expect(result.current.text).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { objectsApi } from '@/lib/api';
|
||||
import {
|
||||
getPreviewKind,
|
||||
getPreviewMime,
|
||||
getPreviewSizeLimit,
|
||||
looksBinary,
|
||||
type PreviewKind,
|
||||
} from '@/lib/preview-utils';
|
||||
|
||||
export interface ObjectPreviewState {
|
||||
kind: PreviewKind;
|
||||
status: 'loading' | 'ready' | 'too-large' | 'unsupported' | 'binary' | 'error';
|
||||
objectUrl: string | null;
|
||||
text: string | null;
|
||||
mediaUrl: string | null;
|
||||
retry: () => void;
|
||||
onMediaError: () => void;
|
||||
}
|
||||
|
||||
// Documents (image, pdf, text) are fetched as blobs through the normal
|
||||
// authenticated API path. Media (video, audio) gets a short-lived tokenized
|
||||
// URL instead, because media elements cannot send an Authorization header
|
||||
// and must stream with Range requests rather than load fully.
|
||||
export function useObjectPreview(
|
||||
bucket: string,
|
||||
objectKey: string,
|
||||
size: number,
|
||||
contentType?: string,
|
||||
): ObjectPreviewState {
|
||||
const kind = getPreviewKind(contentType, objectKey);
|
||||
const sizeLimit = getPreviewSizeLimit(kind);
|
||||
const isDocument = kind === 'image' || kind === 'pdf' || kind === 'text';
|
||||
const isMedia = kind === 'video' || kind === 'audio';
|
||||
const tooLarge = isDocument && sizeLimit !== null && size > sizeLimit;
|
||||
|
||||
const blobQuery = useQuery({
|
||||
queryKey: ['object-preview', bucket, objectKey],
|
||||
queryFn: () => objectsApi.get(bucket, objectKey),
|
||||
enabled: isDocument && !tooLarge,
|
||||
staleTime: Infinity,
|
||||
gcTime: 0,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const urlQuery = useQuery({
|
||||
queryKey: ['object-preview-url', bucket, objectKey],
|
||||
queryFn: () => objectsApi.getPreviewUrl(bucket, objectKey),
|
||||
enabled: isMedia,
|
||||
staleTime: Infinity,
|
||||
gcTime: 0,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||
const [text, setText] = useState<string | null>(null);
|
||||
const [isBinary, setIsBinary] = useState(false);
|
||||
const [mediaFailed, setMediaFailed] = useState(false);
|
||||
const remintedRef = useRef(false);
|
||||
|
||||
// Reset media error state when the target object changes, so a failure on
|
||||
// one object does not leave a later object stuck in the error state. The
|
||||
// hook instance is reused across navigation, it does not remount.
|
||||
useEffect(() => {
|
||||
setMediaFailed(false);
|
||||
remintedRef.current = false;
|
||||
}, [bucket, objectKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const blob = blobQuery.data;
|
||||
if (!blob) return;
|
||||
let cancelled = false;
|
||||
const typed = new Blob([blob], { type: getPreviewMime(kind, contentType, objectKey) });
|
||||
const url = URL.createObjectURL(typed);
|
||||
setObjectUrl(url);
|
||||
if (kind === 'text') {
|
||||
blob.text().then((decoded) => {
|
||||
if (cancelled) return;
|
||||
if (looksBinary(decoded.slice(0, 4096))) setIsBinary(true);
|
||||
else setText(decoded);
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
URL.revokeObjectURL(url);
|
||||
setObjectUrl(null);
|
||||
setText(null);
|
||||
setIsBinary(false);
|
||||
};
|
||||
}, [blobQuery.data, kind, contentType, objectKey]);
|
||||
|
||||
const onMediaError = () => {
|
||||
if (remintedRef.current) {
|
||||
setMediaFailed(true);
|
||||
return;
|
||||
}
|
||||
remintedRef.current = true;
|
||||
urlQuery.refetch();
|
||||
};
|
||||
|
||||
const retry = () => {
|
||||
remintedRef.current = false;
|
||||
setMediaFailed(false);
|
||||
if (isDocument) blobQuery.refetch();
|
||||
if (isMedia) urlQuery.refetch();
|
||||
};
|
||||
|
||||
let status: ObjectPreviewState['status'];
|
||||
if (kind === 'none') status = 'unsupported';
|
||||
else if (tooLarge) status = 'too-large';
|
||||
else if (isBinary) status = 'binary';
|
||||
else if (mediaFailed || blobQuery.isError || urlQuery.isError) status = 'error';
|
||||
else if (isMedia) status = urlQuery.data ? 'ready' : 'loading';
|
||||
else if (kind === 'text') status = text !== null ? 'ready' : 'loading';
|
||||
else status = objectUrl ? 'ready' : 'loading';
|
||||
|
||||
return {
|
||||
kind,
|
||||
status,
|
||||
objectUrl,
|
||||
text,
|
||||
mediaUrl: urlQuery.data?.url ?? null,
|
||||
retry,
|
||||
onMediaError,
|
||||
};
|
||||
}
|
||||
@@ -161,3 +161,77 @@
|
||||
background: color-mix(in srgb, var(--muted-foreground) 30%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
/* Syntax highlighting tokens for the object preview. The dark overrides ride
|
||||
the same root class the theme provider toggles. */
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
color: #6a737d;
|
||||
}
|
||||
.hljs-keyword,
|
||||
.hljs-selector-tag,
|
||||
.hljs-subst {
|
||||
color: #d73a49;
|
||||
}
|
||||
.hljs-number,
|
||||
.hljs-literal,
|
||||
.hljs-variable,
|
||||
.hljs-template-variable {
|
||||
color: #005cc5;
|
||||
}
|
||||
.hljs-string,
|
||||
.hljs-doctag,
|
||||
.hljs-regexp {
|
||||
color: #032f62;
|
||||
}
|
||||
.hljs-title,
|
||||
.hljs-section,
|
||||
.hljs-name {
|
||||
color: #6f42c1;
|
||||
}
|
||||
.hljs-attr,
|
||||
.hljs-attribute,
|
||||
.hljs-built_in {
|
||||
color: #005cc5;
|
||||
}
|
||||
.hljs-meta,
|
||||
.hljs-selector-id,
|
||||
.hljs-selector-class {
|
||||
color: #e36209;
|
||||
}
|
||||
|
||||
.dark .hljs-comment,
|
||||
.dark .hljs-quote {
|
||||
color: #8b949e;
|
||||
}
|
||||
.dark .hljs-keyword,
|
||||
.dark .hljs-selector-tag,
|
||||
.dark .hljs-subst {
|
||||
color: #ff7b72;
|
||||
}
|
||||
.dark .hljs-number,
|
||||
.dark .hljs-literal,
|
||||
.dark .hljs-variable,
|
||||
.dark .hljs-template-variable {
|
||||
color: #79c0ff;
|
||||
}
|
||||
.dark .hljs-string,
|
||||
.dark .hljs-doctag,
|
||||
.dark .hljs-regexp {
|
||||
color: #a5d6ff;
|
||||
}
|
||||
.dark .hljs-title,
|
||||
.dark .hljs-section,
|
||||
.dark .hljs-name {
|
||||
color: #d2a8ff;
|
||||
}
|
||||
.dark .hljs-attr,
|
||||
.dark .hljs-attribute,
|
||||
.dark .hljs-built_in {
|
||||
color: #79c0ff;
|
||||
}
|
||||
.dark .hljs-meta,
|
||||
.dark .hljs-selector-id,
|
||||
.dark .hljs-selector-class {
|
||||
color: #ffa657;
|
||||
}
|
||||
|
||||
@@ -395,6 +395,12 @@ export const objectsApi = {
|
||||
});
|
||||
return response.data.data.url;
|
||||
},
|
||||
|
||||
getPreviewUrl: async (bucket: string, key: string): Promise<{ url: string; expiresAt: string }> => {
|
||||
const response = await api.get(`/v1/buckets/${bucket}/objects/${encodeObjectKey(key)}/preview-url`);
|
||||
const data = response.data.data;
|
||||
return { url: data.url, expiresAt: data.expires_at };
|
||||
},
|
||||
};
|
||||
|
||||
// Access Control API (Users/Keys)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { highlight } from './highlight';
|
||||
|
||||
describe('highlight', () => {
|
||||
it('highlights a known language', () => {
|
||||
const html = highlight('{"a": 1}', 'json');
|
||||
expect(html).toContain('hljs-');
|
||||
});
|
||||
|
||||
it('escapes html in the source', () => {
|
||||
const html = highlight('<script>alert(1)</script>', 'xml');
|
||||
expect(html).not.toContain('<script>');
|
||||
});
|
||||
|
||||
it('falls back to auto detection for null language', () => {
|
||||
const html = highlight('SELECT * FROM t;', null);
|
||||
expect(typeof html).toBe('string');
|
||||
expect(html.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('falls back to auto detection for an unregistered language', () => {
|
||||
const html = highlight('plain words', 'klingon');
|
||||
expect(typeof html).toBe('string');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
// Loaded only via dynamic import() from the text preview renderer, so
|
||||
// highlight.js never enters the main bundle.
|
||||
import hljs from 'highlight.js/lib/core';
|
||||
import bash from 'highlight.js/lib/languages/bash';
|
||||
import css from 'highlight.js/lib/languages/css';
|
||||
import dockerfile from 'highlight.js/lib/languages/dockerfile';
|
||||
import go from 'highlight.js/lib/languages/go';
|
||||
import ini from 'highlight.js/lib/languages/ini';
|
||||
import javascript from 'highlight.js/lib/languages/javascript';
|
||||
import json from 'highlight.js/lib/languages/json';
|
||||
import markdown from 'highlight.js/lib/languages/markdown';
|
||||
import python from 'highlight.js/lib/languages/python';
|
||||
import sql from 'highlight.js/lib/languages/sql';
|
||||
import typescript from 'highlight.js/lib/languages/typescript';
|
||||
import xml from 'highlight.js/lib/languages/xml';
|
||||
import yaml from 'highlight.js/lib/languages/yaml';
|
||||
|
||||
hljs.registerLanguage('bash', bash);
|
||||
hljs.registerLanguage('css', css);
|
||||
hljs.registerLanguage('dockerfile', dockerfile);
|
||||
hljs.registerLanguage('go', go);
|
||||
hljs.registerLanguage('ini', ini);
|
||||
hljs.registerLanguage('javascript', javascript);
|
||||
hljs.registerLanguage('json', json);
|
||||
hljs.registerLanguage('markdown', markdown);
|
||||
hljs.registerLanguage('python', python);
|
||||
hljs.registerLanguage('sql', sql);
|
||||
hljs.registerLanguage('typescript', typescript);
|
||||
hljs.registerLanguage('xml', xml);
|
||||
hljs.registerLanguage('yaml', yaml);
|
||||
|
||||
// Returns highlighted HTML for the source text. hljs escapes the input, so
|
||||
// the output is safe to assign as innerHTML.
|
||||
export function highlight(text: string, language: string | null): string {
|
||||
if (language && hljs.getLanguage(language)) {
|
||||
return hljs.highlight(text, { language }).value;
|
||||
}
|
||||
return hljs.highlightAuto(text).value;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getHighlightLanguage,
|
||||
getPreviewKind,
|
||||
getPreviewMime,
|
||||
getPreviewSizeLimit,
|
||||
looksBinary,
|
||||
IMAGE_PREVIEW_MAX_BYTES,
|
||||
PDF_PREVIEW_MAX_BYTES,
|
||||
TEXT_PREVIEW_MAX_BYTES,
|
||||
} from './preview-utils';
|
||||
|
||||
describe('getPreviewKind', () => {
|
||||
it('trusts a specific content type first', () => {
|
||||
expect(getPreviewKind('image/png', 'noext')).toBe('image');
|
||||
expect(getPreviewKind('video/mp4', 'noext')).toBe('video');
|
||||
expect(getPreviewKind('audio/mpeg', 'noext')).toBe('audio');
|
||||
expect(getPreviewKind('application/pdf', 'noext')).toBe('pdf');
|
||||
expect(getPreviewKind('text/plain', 'noext')).toBe('text');
|
||||
expect(getPreviewKind('application/json', 'noext')).toBe('text');
|
||||
});
|
||||
|
||||
it('ignores content type parameters', () => {
|
||||
expect(getPreviewKind('text/plain; charset=utf-8', 'noext')).toBe('text');
|
||||
});
|
||||
|
||||
it('falls back to the extension when the type is octet-stream', () => {
|
||||
expect(getPreviewKind('application/octet-stream', 'photo.JPG')).toBe('image');
|
||||
expect(getPreviewKind('application/octet-stream', 'clip.mp4')).toBe('video');
|
||||
expect(getPreviewKind('application/octet-stream', 'song.flac')).toBe('audio');
|
||||
expect(getPreviewKind('application/octet-stream', 'doc.pdf')).toBe('pdf');
|
||||
expect(getPreviewKind('application/octet-stream', 'conf.yaml')).toBe('text');
|
||||
expect(getPreviewKind(undefined, 'a/b/c.json')).toBe('text');
|
||||
});
|
||||
|
||||
it('detects svg as image even though the backend rewrites its type', () => {
|
||||
expect(getPreviewKind('application/octet-stream', 'logo.svg')).toBe('image');
|
||||
});
|
||||
|
||||
it('detects Dockerfile without an extension', () => {
|
||||
expect(getPreviewKind(undefined, 'build/Dockerfile')).toBe('text');
|
||||
});
|
||||
|
||||
it('returns none for unknown files', () => {
|
||||
expect(getPreviewKind('application/octet-stream', 'blob.bin')).toBe('none');
|
||||
expect(getPreviewKind(undefined, 'noext')).toBe('none');
|
||||
expect(getPreviewKind(undefined, 'archive.zip')).toBe('none');
|
||||
});
|
||||
|
||||
it('does not treat a dotfile name as an extension', () => {
|
||||
expect(getPreviewKind(undefined, '.gitignore')).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPreviewSizeLimit', () => {
|
||||
it('caps documents per kind and leaves media unlimited', () => {
|
||||
expect(getPreviewSizeLimit('text')).toBe(TEXT_PREVIEW_MAX_BYTES);
|
||||
expect(getPreviewSizeLimit('image')).toBe(IMAGE_PREVIEW_MAX_BYTES);
|
||||
expect(getPreviewSizeLimit('pdf')).toBe(PDF_PREVIEW_MAX_BYTES);
|
||||
expect(getPreviewSizeLimit('video')).toBeNull();
|
||||
expect(getPreviewSizeLimit('audio')).toBeNull();
|
||||
expect(getPreviewSizeLimit('none')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPreviewMime', () => {
|
||||
it('restores the mime type the backend rewrote', () => {
|
||||
expect(getPreviewMime('image', 'application/octet-stream', 'logo.svg')).toBe('image/svg+xml');
|
||||
expect(getPreviewMime('pdf', 'application/octet-stream', 'doc.pdf')).toBe('application/pdf');
|
||||
});
|
||||
it('keeps a usable content type when there is no mapping', () => {
|
||||
expect(getPreviewMime('text', 'text/plain', 'readme.txt')).toBe('text/plain');
|
||||
expect(getPreviewMime('text', undefined, 'readme.weird')).toBe('application/octet-stream');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHighlightLanguage', () => {
|
||||
it('maps common extensions', () => {
|
||||
expect(getHighlightLanguage('a.json')).toBe('json');
|
||||
expect(getHighlightLanguage('a.yml')).toBe('yaml');
|
||||
expect(getHighlightLanguage('a.tsx')).toBe('typescript');
|
||||
expect(getHighlightLanguage('Dockerfile')).toBe('dockerfile');
|
||||
});
|
||||
it('returns null for unmapped extensions', () => {
|
||||
expect(getHighlightLanguage('a.log')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('looksBinary', () => {
|
||||
it('accepts ordinary text with newlines and tabs', () => {
|
||||
expect(looksBinary('hello\n\tworld\r\n')).toBe(false);
|
||||
expect(looksBinary('')).toBe(false);
|
||||
});
|
||||
it('flags replacement-heavy content', () => {
|
||||
expect(looksBinary('���ab')).toBe(true);
|
||||
});
|
||||
it('flags control-character-heavy content', () => {
|
||||
expect(looksBinary('\x00\x01\x02abc')).toBe(true);
|
||||
});
|
||||
it('tolerates a small fraction of oddities', () => {
|
||||
expect(looksBinary('a'.repeat(99) + '\x00')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
export type PreviewKind = 'image' | 'video' | 'audio' | 'pdf' | 'text' | 'none';
|
||||
|
||||
export const TEXT_PREVIEW_MAX_BYTES = 5 * 1024 * 1024;
|
||||
export const TEXT_HIGHLIGHT_MAX_BYTES = 1 * 1024 * 1024;
|
||||
export const IMAGE_PREVIEW_MAX_BYTES = 20 * 1024 * 1024;
|
||||
export const PDF_PREVIEW_MAX_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
const imageMimeByExtension: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
avif: 'image/avif',
|
||||
bmp: 'image/bmp',
|
||||
ico: 'image/x-icon',
|
||||
svg: 'image/svg+xml',
|
||||
};
|
||||
|
||||
const videoExtensions = new Set(['mp4', 'webm', 'ogv', 'mov']);
|
||||
const audioExtensions = new Set(['mp3', 'wav', 'ogg', 'flac', 'm4a']);
|
||||
const textExtensions = new Set([
|
||||
'json', 'yaml', 'yml', 'ini', 'toml', 'xml', 'csv', 'md', 'log', 'txt',
|
||||
'conf', 'cfg', 'env', 'sh', 'bash', 'py', 'js', 'ts', 'jsx', 'tsx',
|
||||
'go', 'rs', 'java', 'c', 'h', 'cpp', 'hpp', 'sql', 'html', 'css', 'dockerfile',
|
||||
]);
|
||||
|
||||
function getExtension(key: string): string {
|
||||
const name = key.split('/').pop() ?? '';
|
||||
if (name.toLowerCase() === 'dockerfile') return 'dockerfile';
|
||||
const dot = name.lastIndexOf('.');
|
||||
return dot > 0 ? name.slice(dot + 1).toLowerCase() : '';
|
||||
}
|
||||
|
||||
// Content-Type first, extension fallback. The fallback matters: Garage often
|
||||
// stores application/octet-stream, and the backend rewrites unsafe types to
|
||||
// it, so a generic type defers to the file extension.
|
||||
export function getPreviewKind(contentType: string | undefined, key: string): PreviewKind {
|
||||
const ct = (contentType ?? '').split(';')[0].trim().toLowerCase();
|
||||
if (ct && ct !== 'application/octet-stream') {
|
||||
if (ct.startsWith('image/')) return 'image';
|
||||
if (ct.startsWith('video/')) return 'video';
|
||||
if (ct.startsWith('audio/')) return 'audio';
|
||||
if (ct === 'application/pdf') return 'pdf';
|
||||
if (ct.startsWith('text/') || ct === 'application/json') return 'text';
|
||||
}
|
||||
const ext = getExtension(key);
|
||||
if (ext in imageMimeByExtension) return 'image';
|
||||
if (videoExtensions.has(ext)) return 'video';
|
||||
if (audioExtensions.has(ext)) return 'audio';
|
||||
if (ext === 'pdf') return 'pdf';
|
||||
if (textExtensions.has(ext)) return 'text';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
// Byte limit per kind. null means no limit because media streams with Range
|
||||
// requests and is never fully loaded into memory.
|
||||
export function getPreviewSizeLimit(kind: PreviewKind): number | null {
|
||||
switch (kind) {
|
||||
case 'text':
|
||||
return TEXT_PREVIEW_MAX_BYTES;
|
||||
case 'image':
|
||||
return IMAGE_PREVIEW_MAX_BYTES;
|
||||
case 'pdf':
|
||||
return PDF_PREVIEW_MAX_BYTES;
|
||||
case 'video':
|
||||
case 'audio':
|
||||
return null;
|
||||
case 'none':
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// The backend rewrites unsafe Content-Types to application/octet-stream, so
|
||||
// blob URLs need their MIME type restored for the SVG <img> case and the
|
||||
// browser PDF viewer.
|
||||
export function getPreviewMime(kind: PreviewKind, contentType: string | undefined, key: string): string {
|
||||
if (kind === 'image') {
|
||||
return imageMimeByExtension[getExtension(key)] ?? contentType ?? 'application/octet-stream';
|
||||
}
|
||||
if (kind === 'pdf') return 'application/pdf';
|
||||
return contentType || 'application/octet-stream';
|
||||
}
|
||||
|
||||
const languageByExtension: Record<string, string> = {
|
||||
json: 'json',
|
||||
yaml: 'yaml',
|
||||
yml: 'yaml',
|
||||
ini: 'ini',
|
||||
toml: 'ini',
|
||||
xml: 'xml',
|
||||
md: 'markdown',
|
||||
sh: 'bash',
|
||||
bash: 'bash',
|
||||
py: 'python',
|
||||
js: 'javascript',
|
||||
jsx: 'javascript',
|
||||
ts: 'typescript',
|
||||
tsx: 'typescript',
|
||||
go: 'go',
|
||||
sql: 'sql',
|
||||
html: 'xml',
|
||||
css: 'css',
|
||||
dockerfile: 'dockerfile',
|
||||
};
|
||||
|
||||
export function getHighlightLanguage(key: string): string | null {
|
||||
return languageByExtension[getExtension(key)] ?? null;
|
||||
}
|
||||
|
||||
// A text preview only makes sense for content that decodes as text. More
|
||||
// than 10 percent replacement or non-whitespace control characters in the
|
||||
// sample means the file is binary despite its name.
|
||||
export function looksBinary(sample: string): boolean {
|
||||
if (sample.length === 0) return false;
|
||||
let suspicious = 0;
|
||||
let total = 0;
|
||||
for (const ch of sample) {
|
||||
total++;
|
||||
const code = ch.codePointAt(0) ?? 0;
|
||||
if (code === 0xfffd || (code < 32 && code !== 9 && code !== 10 && code !== 13)) {
|
||||
suspicious++;
|
||||
}
|
||||
}
|
||||
return suspicious / total > 0.1;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { afterEach } from 'vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
|
||||
// Testing Library's automatic cleanup only self-registers when a global
|
||||
// afterEach exists. This harness does not set test.globals, so register it
|
||||
// explicitly to unmount rendered trees between tests.
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
Reference in New Issue
Block a user