mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-30 09:02:56 +08:00
Compare commits
18 Commits
fix/video-
...
fix/voice-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14e16dac58 | ||
|
|
a1c48e5b1d | ||
|
|
0e6373305b | ||
|
|
43c0cca96e | ||
|
|
af16d8ebdc | ||
|
|
b0a49120cc | ||
|
|
8cbfac6ab1 | ||
|
|
9919851be7 | ||
|
|
66b0766013 | ||
|
|
95a75aac47 | ||
|
|
952ddb9eb7 | ||
|
|
7f8ae7023d | ||
|
|
1203760ba8 | ||
|
|
f285715e31 | ||
|
|
cb3ad16139 | ||
|
|
32a6405537 | ||
|
|
711936d279 | ||
|
|
d05585e1f4 |
@@ -1,77 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { QuestionAnswerContent } from './QuestionAnswerContent';
|
||||
|
||||
// Regression coverage for the chat-interface crash where an AskUserQuestion
|
||||
// payload loaded from a session transcript arrives with a non-array `questions`
|
||||
// or a question missing its `options` array. Rendering must degrade gracefully
|
||||
// instead of throwing "TypeError: e.map is not a function".
|
||||
|
||||
test('renders without throwing when questions is a non-array value', () => {
|
||||
assert.doesNotThrow(() => {
|
||||
renderToStaticMarkup(
|
||||
React.createElement(QuestionAnswerContent, {
|
||||
// Malformed: object instead of an array
|
||||
questions: { 0: { question: 'q?', options: [{ label: 'a' }] } } as never,
|
||||
answers: {},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('renders without throwing when a question is missing options[]', () => {
|
||||
assert.doesNotThrow(() => {
|
||||
renderToStaticMarkup(
|
||||
React.createElement(QuestionAnswerContent, {
|
||||
questions: [{ question: 'Pick one?', header: 'H' } as never],
|
||||
answers: { 'Pick one?': 'X' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('renders without throwing when options[] contains malformed entries', () => {
|
||||
assert.doesNotThrow(() => {
|
||||
renderToStaticMarkup(
|
||||
React.createElement(QuestionAnswerContent, {
|
||||
questions: [{ question: 'Pick one?', options: [null, 'oops', { label: 'A' }] } as never],
|
||||
answers: { 'Pick one?': 'A, Custom' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('renders without throwing when a questions entry is null/non-object', () => {
|
||||
assert.doesNotThrow(() => {
|
||||
renderToStaticMarkup(
|
||||
React.createElement(QuestionAnswerContent, {
|
||||
questions: [null, 'oops', { question: 'Ok?', options: [{ label: 'A' }] }] as never,
|
||||
answers: {},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('renders without throwing when an answer is a non-string value', () => {
|
||||
assert.doesNotThrow(() => {
|
||||
renderToStaticMarkup(
|
||||
React.createElement(QuestionAnswerContent, {
|
||||
questions: [{ question: 'Pick one?', options: [{ label: 'A' }] }],
|
||||
// Malformed: answer is an object instead of the expected string
|
||||
answers: { 'Pick one?': { unexpected: true } } as never,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('still renders a well-formed question + answer', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(QuestionAnswerContent, {
|
||||
questions: [{ question: 'Pick one?', header: 'H', options: [{ label: 'A' }, { label: 'B' }] }],
|
||||
answers: { 'Pick one?': 'A' },
|
||||
}),
|
||||
);
|
||||
assert.ok(html.includes('Pick one?'));
|
||||
});
|
||||
@@ -15,11 +15,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
||||
}) => {
|
||||
const [expandedIdx, setExpandedIdx] = useState<number | null>(null);
|
||||
|
||||
// Tool inputs are runtime data loaded from session transcripts and may be
|
||||
// malformed (e.g. `questions` arriving as a non-array). Guard with
|
||||
// Array.isArray so a single bad payload can't crash the whole chat view
|
||||
// with "e.map is not a function".
|
||||
if (!Array.isArray(questions) || questions.length === 0) {
|
||||
if (!questions || questions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -28,23 +24,11 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
||||
|
||||
return (
|
||||
<div className={`space-y-2 ${className}`}>
|
||||
{questions.map((rawQuestion, idx) => {
|
||||
// Entries come from session transcripts and may be malformed; skip
|
||||
// anything that isn't a proper question object with a string prompt.
|
||||
if (!rawQuestion || typeof rawQuestion !== 'object' || typeof rawQuestion.question !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const q = rawQuestion;
|
||||
{questions.map((q, idx) => {
|
||||
const answer = answers?.[q.question];
|
||||
// `answer` may be a non-string (or absent) in malformed payloads.
|
||||
const answerLabels = typeof answer === 'string' ? answer.split(', ') : [];
|
||||
const answerLabels = answer ? answer.split(', ') : [];
|
||||
const skipped = !answer;
|
||||
const isExpanded = expandedIdx === idx;
|
||||
// `options` is typed as an array but comes from untrusted runtime data;
|
||||
// keep only valid entries so `.some`/`.map` below never throw.
|
||||
const options = Array.isArray(q.options)
|
||||
? q.options.filter((opt) => opt && typeof opt === 'object' && typeof opt.label === 'string')
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -90,7 +74,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
||||
{!isExpanded && answerLabels.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{answerLabels.map((lbl) => {
|
||||
const isCustom = !options.some(o => o.label === lbl);
|
||||
const isCustom = !q.options.some(o => o.label === lbl);
|
||||
return (
|
||||
<span
|
||||
key={lbl}
|
||||
@@ -126,7 +110,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-100 px-3 pb-2.5 pt-0.5 dark:border-gray-700/40">
|
||||
<div className="ml-6.5 space-y-1">
|
||||
{options.map((opt) => {
|
||||
{q.options.map((opt) => {
|
||||
const wasSelected = answerLabels.includes(opt.label);
|
||||
return (
|
||||
<div
|
||||
@@ -164,7 +148,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
||||
);
|
||||
})}
|
||||
|
||||
{answerLabels.filter(lbl => !options.some(o => o.label === lbl)).map(lbl => (
|
||||
{answerLabels.filter(lbl => !q.options.some(o => o.label === lbl)).map(lbl => (
|
||||
<div
|
||||
key={lbl}
|
||||
className="flex items-start gap-2 rounded-lg border border-blue-200/60 bg-blue-50/80 px-2.5 py-1.5 text-[12px] dark:border-blue-800/40 dark:bg-blue-900/20"
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import { api } from '../../../utils/api';
|
||||
import type { CodeEditorFile } from '../types/types';
|
||||
import { isBinaryFile } from '../utils/binaryFile';
|
||||
import { getPreviewKind } from '../utils/previewableFile';
|
||||
|
||||
type UseCodeEditorDocumentParams = {
|
||||
file: CodeEditorFile;
|
||||
@@ -24,9 +23,6 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [isBinary, setIsBinary] = useState(false);
|
||||
// Some binaries (images, PDFs, audio, video) can be rendered natively, so the
|
||||
// editor shows an inline preview instead of the generic binary placeholder.
|
||||
const previewKind = getPreviewKind(file.name);
|
||||
// `fileProjectId` is the DB primary key passed down from the editor sidebar;
|
||||
// the fallback to `projectPath` preserves older callers that didn't yet
|
||||
// propagate the identifier.
|
||||
@@ -42,19 +38,8 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
||||
setLoading(true);
|
||||
setIsBinary(false);
|
||||
|
||||
// Natively previewable media (image/pdf/audio/video) is rendered by
|
||||
// CodeEditorMediaPreview, so there is nothing to read as text here.
|
||||
// Clear any buffer left over from a previously opened text file so a
|
||||
// stray save can't write stale content over the binary file.
|
||||
if (getPreviewKind(file.name)) {
|
||||
setContent('');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if file is binary by extension
|
||||
if (isBinaryFile(file.name)) {
|
||||
setContent('');
|
||||
setIsBinary(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -91,12 +76,6 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
||||
}, [file.diffInfo, file.name, fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectId]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
// Preview-only and binary files have no editable text buffer; never write
|
||||
// them back (e.g. via Cmd/Ctrl+S) or we'd corrupt the file on disk.
|
||||
if (previewKind || isBinaryFile(fileName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
|
||||
@@ -130,7 +109,7 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [content, filePath, fileProjectId, previewKind, fileName]);
|
||||
}, [content, filePath, fileProjectId]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
@@ -155,8 +134,6 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
||||
saveSuccess,
|
||||
saveError,
|
||||
isBinary,
|
||||
previewKind,
|
||||
fileProjectId,
|
||||
handleSave,
|
||||
handleDownload,
|
||||
};
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
// Some binary files can't be edited as text, but the browser can still render
|
||||
// them natively (images, PDFs, audio, video). For those we show an inline
|
||||
// preview instead of the generic "binary file" placeholder. Anything not listed
|
||||
// here (zip, exe, avi, mkv, fonts, ...) falls through to the binary message.
|
||||
|
||||
export type PreviewKind = 'image' | 'pdf' | 'video' | 'audio';
|
||||
|
||||
// Single source of truth: every extension the browser can preview, mapped to the
|
||||
// MIME type we apply when the server response has a missing/generic Content-Type.
|
||||
// The preview kind is derived from the MIME type so the two never drift apart.
|
||||
// Formats browsers generally can't play (avi, mkv, flv, wmv) are intentionally
|
||||
// absent and keep the binary fallback.
|
||||
const EXTENSION_MIME: Record<string, string> = {
|
||||
// Images
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
svg: 'image/svg+xml',
|
||||
webp: 'image/webp',
|
||||
ico: 'image/x-icon',
|
||||
bmp: 'image/bmp',
|
||||
avif: 'image/avif',
|
||||
apng: 'image/apng',
|
||||
// PDF
|
||||
pdf: 'application/pdf',
|
||||
// Video
|
||||
mp4: 'video/mp4',
|
||||
webm: 'video/webm',
|
||||
ogv: 'video/ogg',
|
||||
mov: 'video/quicktime',
|
||||
m4v: 'video/x-m4v',
|
||||
// Audio
|
||||
mp3: 'audio/mpeg',
|
||||
wav: 'audio/wav',
|
||||
m4a: 'audio/mp4',
|
||||
aac: 'audio/aac',
|
||||
flac: 'audio/flac',
|
||||
opus: 'audio/opus',
|
||||
oga: 'audio/ogg',
|
||||
ogg: 'audio/ogg',
|
||||
weba: 'audio/webm',
|
||||
};
|
||||
|
||||
const extensionOf = (filename: string): string => filename.split('.').pop()?.toLowerCase() ?? '';
|
||||
|
||||
const kindForMime = (mime: string): PreviewKind | null => {
|
||||
if (mime === 'application/pdf') return 'pdf';
|
||||
if (mime.startsWith('image/')) return 'image';
|
||||
if (mime.startsWith('video/')) return 'video';
|
||||
if (mime.startsWith('audio/')) return 'audio';
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getPreviewKind = (filename: string): PreviewKind | null => {
|
||||
const mime = EXTENSION_MIME[extensionOf(filename)];
|
||||
return mime ? kindForMime(mime) : null;
|
||||
};
|
||||
|
||||
// MIME type to fall back to when the server returns no/generic Content-Type.
|
||||
// Returns undefined for non-previewable extensions.
|
||||
export const getPreviewMimeType = (filename: string): string | undefined =>
|
||||
EXTENSION_MIME[extensionOf(filename)];
|
||||
@@ -1,9 +1,8 @@
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { unifiedMergeView } from '@codemirror/merge';
|
||||
import type { Extension } from '@codemirror/state';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
|
||||
import { useCodeEditorDocument } from '../hooks/useCodeEditorDocument';
|
||||
import { useCodeEditorSettings } from '../hooks/useCodeEditorSettings';
|
||||
@@ -12,13 +11,11 @@ import type { CodeEditorFile } from '../types/types';
|
||||
import { createMinimapExtension, createScrollToFirstChunkExtension, getLanguageExtensions } from '../utils/editorExtensions';
|
||||
import { getEditorStyles } from '../utils/editorStyles';
|
||||
import { createEditorToolbarPanelExtension } from '../utils/editorToolbarPanel';
|
||||
|
||||
import CodeEditorFooter from './subcomponents/CodeEditorFooter';
|
||||
import CodeEditorHeader from './subcomponents/CodeEditorHeader';
|
||||
import CodeEditorLoadingState from './subcomponents/CodeEditorLoadingState';
|
||||
import CodeEditorSurface from './subcomponents/CodeEditorSurface';
|
||||
import CodeEditorBinaryFile from './subcomponents/CodeEditorBinaryFile';
|
||||
import CodeEditorMediaPreview from './subcomponents/CodeEditorMediaPreview';
|
||||
|
||||
type CodeEditorProps = {
|
||||
file: CodeEditorFile;
|
||||
@@ -61,8 +58,6 @@ export default function CodeEditor({
|
||||
saveSuccess,
|
||||
saveError,
|
||||
isBinary,
|
||||
previewKind,
|
||||
fileProjectId,
|
||||
handleSave,
|
||||
handleDownload,
|
||||
} = useCodeEditorDocument({
|
||||
@@ -75,29 +70,6 @@ export default function CodeEditor({
|
||||
return extension === 'md' || extension === 'markdown';
|
||||
}, [file.name]);
|
||||
|
||||
const isHtmlPreviewFile = useMemo(() => {
|
||||
const extension = file.name.split('.').pop()?.toLowerCase();
|
||||
return extension === 'html' || extension === 'htm';
|
||||
}, [file.name]);
|
||||
|
||||
const openHtmlPreview = useCallback(() => {
|
||||
const previewWindow = window.open('', '_blank');
|
||||
if (!previewWindow) return;
|
||||
|
||||
previewWindow.opener = null;
|
||||
previewWindow.document.title = file.name;
|
||||
previewWindow.document.body.style.margin = '0';
|
||||
|
||||
const iframe = previewWindow.document.createElement('iframe');
|
||||
iframe.title = file.name;
|
||||
iframe.sandbox.add('allow-forms', 'allow-modals', 'allow-popups', 'allow-scripts');
|
||||
iframe.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;border:0;background:white';
|
||||
|
||||
iframe.srcdoc = content;
|
||||
|
||||
previewWindow.document.body.appendChild(iframe);
|
||||
}, [content, file.name]);
|
||||
|
||||
const minimapExtension = useMemo(
|
||||
() => (
|
||||
createMinimapExtension({
|
||||
@@ -190,30 +162,6 @@ export default function CodeEditor({
|
||||
);
|
||||
}
|
||||
|
||||
// Natively previewable media (image/pdf/audio/video) is rendered inline
|
||||
// instead of showing the generic "cannot be displayed" placeholder.
|
||||
if (previewKind) {
|
||||
return (
|
||||
<CodeEditorMediaPreview
|
||||
file={file}
|
||||
kind={previewKind}
|
||||
projectId={fileProjectId}
|
||||
isSidebar={isSidebar}
|
||||
isFullscreen={isFullscreen}
|
||||
onClose={onClose}
|
||||
onToggleFullscreen={() => setIsFullscreen((previous) => !previous)}
|
||||
labels={{
|
||||
loading: t('filePreview.loading', 'Loading preview...'),
|
||||
error: t('filePreview.error', 'Unable to display this file.'),
|
||||
openInNewTab: t('filePreview.openInNewTab', 'Open in new tab'),
|
||||
fullscreen: t('actions.fullscreen', 'Fullscreen'),
|
||||
exitFullscreen: t('actions.exitFullscreen', 'Exit fullscreen'),
|
||||
close: t('actions.close', 'Close'),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Binary file display
|
||||
if (isBinary) {
|
||||
return (
|
||||
@@ -249,12 +197,10 @@ export default function CodeEditor({
|
||||
isSidebar={isSidebar}
|
||||
isFullscreen={isFullscreen}
|
||||
isMarkdownFile={isMarkdownFile}
|
||||
isHtmlPreviewFile={isHtmlPreviewFile}
|
||||
markdownPreview={markdownPreview}
|
||||
saving={saving}
|
||||
saveSuccess={saveSuccess}
|
||||
onToggleMarkdownPreview={() => setMarkdownPreview((previous) => !previous)}
|
||||
onOpenHtmlPreview={openHtmlPreview}
|
||||
onOpenSettings={() => paletteOps.openSettings('appearance')}
|
||||
onDownload={handleDownload}
|
||||
onSave={handleSave}
|
||||
@@ -264,7 +210,6 @@ export default function CodeEditor({
|
||||
showingChanges: t('header.showingChanges'),
|
||||
editMarkdown: t('actions.editMarkdown'),
|
||||
previewMarkdown: t('actions.previewMarkdown'),
|
||||
previewHtml: t('actions.previewHtml', 'Open HTML preview in new tab'),
|
||||
settings: t('toolbar.settings'),
|
||||
download: t('actions.download'),
|
||||
save: t('actions.save'),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Code2, Download, Eye, Maximize2, Minimize2, Save, Settings as SettingsIcon, X } from 'lucide-react';
|
||||
|
||||
import type { CodeEditorFile } from '../../types/types';
|
||||
|
||||
type CodeEditorHeaderProps = {
|
||||
@@ -7,12 +6,10 @@ type CodeEditorHeaderProps = {
|
||||
isSidebar: boolean;
|
||||
isFullscreen: boolean;
|
||||
isMarkdownFile: boolean;
|
||||
isHtmlPreviewFile: boolean;
|
||||
markdownPreview: boolean;
|
||||
saving: boolean;
|
||||
saveSuccess: boolean;
|
||||
onToggleMarkdownPreview: () => void;
|
||||
onOpenHtmlPreview: () => void;
|
||||
onOpenSettings: () => void;
|
||||
onDownload: () => void;
|
||||
onSave: () => void;
|
||||
@@ -22,7 +19,6 @@ type CodeEditorHeaderProps = {
|
||||
showingChanges: string;
|
||||
editMarkdown: string;
|
||||
previewMarkdown: string;
|
||||
previewHtml: string;
|
||||
settings: string;
|
||||
download: string;
|
||||
save: string;
|
||||
@@ -39,12 +35,10 @@ export default function CodeEditorHeader({
|
||||
isSidebar,
|
||||
isFullscreen,
|
||||
isMarkdownFile,
|
||||
isHtmlPreviewFile,
|
||||
markdownPreview,
|
||||
saving,
|
||||
saveSuccess,
|
||||
onToggleMarkdownPreview,
|
||||
onOpenHtmlPreview,
|
||||
onOpenSettings,
|
||||
onDownload,
|
||||
onSave,
|
||||
@@ -88,17 +82,6 @@ export default function CodeEditorHeader({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isHtmlPreviewFile && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenHtmlPreview}
|
||||
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
||||
title={labels.previewHtml}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSettings}
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { authenticatedFetch } from '../../../../utils/api';
|
||||
import type { CodeEditorFile } from '../../types/types';
|
||||
import { getPreviewMimeType, type PreviewKind } from '../../utils/previewableFile';
|
||||
|
||||
type CodeEditorMediaPreviewProps = {
|
||||
file: CodeEditorFile;
|
||||
kind: PreviewKind;
|
||||
// DB projectId used to build the raw-content URL; falls back to projectPath
|
||||
// for older callers, mirroring useCodeEditorDocument.
|
||||
projectId?: string;
|
||||
isSidebar: boolean;
|
||||
isFullscreen: boolean;
|
||||
onClose: () => void;
|
||||
onToggleFullscreen: () => void;
|
||||
labels: {
|
||||
loading: string;
|
||||
error: string;
|
||||
openInNewTab: string;
|
||||
fullscreen: string;
|
||||
exitFullscreen: string;
|
||||
close: string;
|
||||
};
|
||||
};
|
||||
|
||||
// Reject a "PDF" whose bytes aren't actually a PDF before handing it to the
|
||||
// same-origin iframe, so a mislabeled HTML/SVG file can't run in the app origin.
|
||||
const PDF_HEADER_SCAN_BYTES = 1024;
|
||||
|
||||
const looksLikePdf = async (blob: Blob): Promise<boolean> => {
|
||||
const header = await blob.slice(0, PDF_HEADER_SCAN_BYTES).arrayBuffer();
|
||||
// PDFs must contain the "%PDF-" marker at the very start of the file.
|
||||
return new TextDecoder('latin1').decode(header).includes('%PDF-');
|
||||
};
|
||||
|
||||
export default function CodeEditorMediaPreview({
|
||||
file,
|
||||
kind,
|
||||
projectId,
|
||||
isSidebar,
|
||||
isFullscreen,
|
||||
onClose,
|
||||
onToggleFullscreen,
|
||||
labels,
|
||||
}: CodeEditorMediaPreviewProps) {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// Identifies which file the current `url` was loaded for. Rendering is gated on
|
||||
// this so a blob from a previously-opened file can never show under the new
|
||||
// file (the editor reuses this component instance across files).
|
||||
const [loadedKey, setLoadedKey] = useState<string | null>(null);
|
||||
const sourceKey = `${projectId ?? ''}:${file.path}:${kind}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) {
|
||||
setUrl(null);
|
||||
setLoadedKey(null);
|
||||
setError(labels.error);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let objectUrl: string | null = null;
|
||||
const controller = new AbortController();
|
||||
|
||||
const loadMedia = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setUrl(null);
|
||||
|
||||
// The content endpoint requires the auth header, so we fetch the bytes
|
||||
// ourselves and hand the media element a blob URL instead of a bare src.
|
||||
// Fetching a blob (rather than streaming) also lets <video>/<audio> seek.
|
||||
const contentUrl = `/api/projects/${projectId}/files/content?path=${encodeURIComponent(file.path)}`;
|
||||
const response = await authenticatedFetch(contentUrl, { signal: controller.signal });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
|
||||
// Pick the MIME type to expose to the browser. Preserve a valid
|
||||
// Content-Type from the server, but supply an extension-specific
|
||||
// default when it is missing or generic (application/octet-stream),
|
||||
// otherwise formats like webm/ogg/flac/svg won't render.
|
||||
const fallbackMime = getPreviewMimeType(file.name);
|
||||
const isGenericType = !blob.type || blob.type === 'application/octet-stream';
|
||||
const isMislabeledVideo = kind === 'video' && Boolean(fallbackMime) && !blob.type.startsWith('video/');
|
||||
let outType = isGenericType || isMislabeledVideo ? (fallbackMime ?? blob.type) : blob.type;
|
||||
|
||||
if (kind === 'pdf') {
|
||||
// The PDF renders in a same-origin <iframe>, so verify the bytes are
|
||||
// really a PDF and pin the type to application/pdf. That forces the
|
||||
// browser's PDF handler and prevents a mislabeled HTML/SVG file from
|
||||
// executing scripts in the app's origin.
|
||||
if (!(await looksLikePdf(blob))) {
|
||||
throw new Error('File is not a valid PDF');
|
||||
}
|
||||
outType = 'application/pdf';
|
||||
}
|
||||
|
||||
const typed = outType && outType !== blob.type ? new Blob([blob], { type: outType }) : blob;
|
||||
objectUrl = URL.createObjectURL(typed);
|
||||
|
||||
// The cleanup may have already run (deps changed during an await), in
|
||||
// which case it revoked nothing because objectUrl was still null. Don't
|
||||
// publish a URL the cleanup will never revoke — drop it ourselves.
|
||||
if (controller.signal.aborted) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
objectUrl = null;
|
||||
return;
|
||||
}
|
||||
|
||||
setUrl(objectUrl);
|
||||
setLoadedKey(sourceKey);
|
||||
} catch (loadError: unknown) {
|
||||
if (loadError instanceof Error && loadError.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
console.error('Error loading preview:', loadError);
|
||||
setError(labels.error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadMedia();
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [file.path, file.name, projectId, kind, sourceKey, labels.error]);
|
||||
|
||||
// Only expose the blob once it matches the file currently being shown, so a
|
||||
// stale URL from the previous file is never rendered during a switch.
|
||||
const currentUrl = url && loadedKey === sourceKey ? url : null;
|
||||
|
||||
// SVGs render safely inline via <img> (scripts don't execute there), but the
|
||||
// open-in-new-tab link is a top-level navigation. A blob URL inherits the
|
||||
// app's origin, so a user-controlled SVG with an embedded <script> would run
|
||||
// as same-origin script. Withhold the new-tab action for SVGs.
|
||||
const isSvg = getPreviewMimeType(file.name) === 'image/svg+xml';
|
||||
const canOpenInNewTab = Boolean(currentUrl) && !isSvg;
|
||||
|
||||
const renderMedia = () => {
|
||||
if (!currentUrl) return null;
|
||||
switch (kind) {
|
||||
case 'image':
|
||||
return (
|
||||
<img
|
||||
src={currentUrl}
|
||||
alt={file.name}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
);
|
||||
case 'pdf':
|
||||
// Not sandboxed on purpose: the browser's built-in PDF viewer refuses to
|
||||
// load inside a sandboxed frame (any `sandbox` value yields a broken
|
||||
// viewer). Script execution is instead prevented upstream by validating
|
||||
// the PDF magic bytes and pinning the blob's MIME type to application/pdf.
|
||||
return <iframe src={currentUrl} title={file.name} className="h-full w-full border-0 bg-white" />;
|
||||
case 'video':
|
||||
return (
|
||||
<video src={currentUrl} controls className="max-h-full max-w-full" autoPlay={false}>
|
||||
{labels.error}
|
||||
</video>
|
||||
);
|
||||
case 'audio':
|
||||
return (
|
||||
<div className="flex w-full max-w-xl flex-col items-center gap-4 px-6">
|
||||
<p className="max-w-full truncate text-sm text-muted-foreground">{file.name}</p>
|
||||
<audio src={currentUrl} controls className="w-full">
|
||||
{labels.error}
|
||||
</audio>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const previewBody = (
|
||||
<div className="relative flex h-full w-full flex-col items-center justify-center bg-muted/30 p-2">
|
||||
{loading && (
|
||||
<div className="text-sm text-muted-foreground">{labels.loading}</div>
|
||||
)}
|
||||
|
||||
{!loading && currentUrl && renderMedia()}
|
||||
|
||||
{!loading && !currentUrl && (
|
||||
<div className="flex flex-col items-center gap-3 p-8 text-center text-muted-foreground">
|
||||
<p className="text-sm">{error || labels.error}</p>
|
||||
<p className="break-all text-xs">{file.path}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const headerActions = (
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
{canOpenInNewTab && currentUrl && (
|
||||
<a
|
||||
href={currentUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
||||
aria-label={labels.openInNewTab}
|
||||
title={labels.openInNewTab}
|
||||
>
|
||||
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
{!isSidebar && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleFullscreen}
|
||||
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
||||
aria-label={isFullscreen ? labels.exitFullscreen : labels.fullscreen}
|
||||
title={isFullscreen ? labels.exitFullscreen : labels.fullscreen}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 9V4.5M9 9H4.5M9 9L3.5 3.5M9 15v4.5M9 15H4.5M9 15l-5.5 5.5M15 9h4.5M15 9V4.5M15 9l5.5-5.5M15 15h4.5M15 15v4.5m0-4.5l5.5 5.5" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
||||
aria-label={labels.close}
|
||||
title={labels.close}
|
||||
>
|
||||
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const header = (
|
||||
<div className="flex flex-shrink-0 items-center justify-between border-b border-border px-3 py-1.5">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<h3 className="truncate text-sm font-medium text-gray-900 dark:text-white">{file.name}</h3>
|
||||
</div>
|
||||
{headerActions}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isSidebar) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-background">
|
||||
{header}
|
||||
{previewBody}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const containerClassName = isFullscreen
|
||||
? 'fixed inset-0 z-[9999] bg-background flex flex-col'
|
||||
: 'fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center md:p-4';
|
||||
|
||||
const innerClassName = isFullscreen
|
||||
? 'bg-background flex flex-col w-full h-full'
|
||||
: 'bg-background shadow-2xl flex flex-col w-full h-full md:rounded-lg md:shadow-2xl md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]';
|
||||
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
<div className={innerClassName}>
|
||||
{header}
|
||||
{previewBody}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -32,10 +32,5 @@
|
||||
"binaryFile": {
|
||||
"title": "Binary File",
|
||||
"message": "The file \"{{fileName}}\" cannot be displayed in the text editor because it is a binary file."
|
||||
},
|
||||
"filePreview": {
|
||||
"loading": "Loading preview...",
|
||||
"error": "Unable to display this file.",
|
||||
"openInNewTab": "Open in new tab"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user