feat(file-preview): render HTML files inline (#15671)

 feat(file-preview): render html files inline
This commit is contained in:
Arvin Xu
2026-06-11 02:39:05 +08:00
committed by GitHub
parent 914976a52f
commit 686778fe51
13 changed files with 287 additions and 122 deletions
+1
View File
@@ -1009,6 +1009,7 @@
"workingPanel.localFile.error": "Couldn't load this file",
"workingPanel.localFile.preview.raw": "Raw",
"workingPanel.localFile.preview.render": "Preview",
"workingPanel.localFile.preview.source": "Source",
"workingPanel.localFile.truncated": "File preview truncated to {{limit}} characters",
"workingPanel.progress": "Progress",
"workingPanel.progress.allCompleted": "All tasks completed",
+1
View File
@@ -1009,6 +1009,7 @@
"workingPanel.localFile.error": "无法加载此文件",
"workingPanel.localFile.preview.raw": "原文",
"workingPanel.localFile.preview.render": "预览",
"workingPanel.localFile.preview.source": "源码",
"workingPanel.localFile.truncated": "文件预览被截断至 {{limit}} 个字符",
"workingPanel.progress": "进度",
"workingPanel.progress.allCompleted": "所有任务已完成",
@@ -7,6 +7,7 @@ import React, { memo } from 'react';
import { useTranslation } from 'react-i18next';
import FileIcon from '@/components/FileIcon';
import { InlineHtmlPreview, isHtmlFile } from '@/components/HtmlPreview';
const styles = createStaticStyles(({ css, cssVar }) => ({
actions: css`
@@ -100,6 +101,7 @@ const ReadFileView = memo<ReadFileState>(
const { t } = useTranslation('tool');
const { openFile, openFolder, displayRelativePath } = useToolRenderCapabilities();
const filename = filenameProp || path.split('/').pop() || path;
const isHtml = isHtmlFile({ fileName: filename, fileType, path });
const handleOpenFile = openFile
? (e: React.MouseEvent) => {
@@ -192,8 +194,13 @@ const ReadFileView = memo<ReadFileState>(
</Text>
</Flexbox>
<Flexbox className={styles.previewBox} style={{ maxHeight: 240 }}>
{fileType === 'md' ? (
<Flexbox
className={styles.previewBox}
style={{ height: isHtml ? 240 : undefined, maxHeight: 240 }}
>
{isHtml ? (
<InlineHtmlPreview content={content} />
) : fileType === 'md' ? (
<Markdown style={{ overflow: 'auto' }}>{content}</Markdown>
) : (
<div className={styles.previewText} style={{ width: '100%' }}>
@@ -6,6 +6,7 @@ import { ChevronRight } from 'lucide-react';
import path from 'path-browserify-esm';
import { memo } from 'react';
import { InlineHtmlPreview, isHtmlFile } from '@/components/HtmlPreview';
import { LocalFile, LocalFolder } from '@/features/LocalFile';
const styles = createStaticStyles(({ css, cssVar }) => ({
@@ -36,12 +37,13 @@ const WriteFile = memo<BuiltinRenderProps<WriteLocalFileParams>>(({ args }) => {
const { base, dir } = path.parse(args.path);
const ext = path.extname(args.path).slice(1).toLowerCase();
const isHtml = isHtmlFile({ path: args.path });
const isMarkdown = ext === 'md' || ext === 'mdx';
// Code-type files render as a "new file" unified diff so the visual is
// consistent with EditLocalFile's PatchDiff. Markdown keeps its rendered
// preview because a rendered doc reads better than an all-green diff.
if (!isMarkdown && args.content) {
if (!isMarkdown && !isHtml && args.content) {
return (
<PatchDiff
fileName={base}
@@ -63,10 +65,17 @@ const WriteFile = memo<BuiltinRenderProps<WriteLocalFileParams>>(({ args }) => {
</Flexbox>
{args.content && (
<Flexbox className={styles.previewBox}>
<Markdown style={{ maxHeight: 240, overflow: 'auto', padding: '0 8px' }} variant={'chat'}>
{args.content}
</Markdown>
<Flexbox className={styles.previewBox} style={{ height: isHtml ? 260 : undefined }}>
{isHtml ? (
<InlineHtmlPreview content={args.content} />
) : (
<Markdown
style={{ maxHeight: 240, overflow: 'auto', padding: '0 8px' }}
variant={'chat'}
>
{args.content}
</Markdown>
)}
</Flexbox>
)}
</Flexbox>
+1
View File
@@ -1111,6 +1111,7 @@ export default {
'workingPanel.localFile.error': "Couldn't load this file",
'workingPanel.localFile.preview.raw': 'Raw',
'workingPanel.localFile.preview.render': 'Preview',
'workingPanel.localFile.preview.source': 'Source',
'workingPanel.localFile.truncated': 'File preview truncated to {{limit}} characters',
'workingPanel.skills.empty': 'No skills available',
'workingPanel.skills.section.agent': 'Agent skills',
@@ -0,0 +1,39 @@
import { HtmlPreview } from '@lobehub/ui';
import type { CSSProperties } from 'react';
import { memo } from 'react';
const hideHtmlPreviewActions = () => null;
interface InlineHtmlPreviewProps {
animated?: boolean;
className?: string;
content: string;
height?: CSSProperties['height'];
style?: CSSProperties;
width?: CSSProperties['width'];
}
const InlineHtmlPreview = memo<InlineHtmlPreviewProps>(
({ animated, className, content, height = '100%', style, width = '100%' }) => (
<HtmlPreview
actionsRender={hideHtmlPreviewActions}
animated={animated}
className={className}
copyable={false}
downloadable={false}
shadow={false}
style={{ height, minHeight: 0, overflow: 'hidden', width, ...style }}
variant={'borderless'}
styles={{
content: { height: '100%' },
iframe: { height: '100%' },
}}
>
{content}
</HtmlPreview>
),
);
InlineHtmlPreview.displayName = 'InlineHtmlPreview';
export default InlineHtmlPreview;
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { isHtmlFile } from './fileType';
describe('isHtmlFile', () => {
it('detects HTML files by MIME type', () => {
expect(isHtmlFile({ fileType: 'text/html' })).toBe(true);
expect(isHtmlFile({ fileType: 'text/html; charset=utf-8' })).toBe(true);
expect(isHtmlFile({ fileType: 'application/xhtml+xml' })).toBe(true);
});
it('detects HTML files by filename or path extension', () => {
expect(isHtmlFile({ fileName: 'preview.HTML' })).toBe(true);
expect(isHtmlFile({ path: '/tmp/demo.htm' })).toBe(true);
expect(isHtmlFile({ fileName: 'preview', path: '/tmp/demo.html' })).toBe(true);
});
it('rejects non-HTML files', () => {
expect(isHtmlFile({ fileName: 'preview.tsx', fileType: 'text/plain' })).toBe(false);
expect(isHtmlFile({ path: '/tmp/html-preview.ts' })).toBe(false);
expect(isHtmlFile({})).toBe(false);
});
});
+27
View File
@@ -0,0 +1,27 @@
const HTML_FILE_EXTENSIONS = ['.html', '.htm'];
const HTML_FILE_TYPES = new Set(['html', 'htm', 'text/html', 'application/xhtml+xml']);
const normalizeFileType = (fileType?: string | null) =>
fileType?.split(';')[0].trim().toLowerCase();
interface HtmlFileFields {
fileName?: string | null;
fileType?: string | null;
path?: string | null;
}
export const isHtmlFile = ({ fileName, fileType, path }: HtmlFileFields): boolean => {
const normalizedFileType = normalizeFileType(fileType);
if (normalizedFileType && HTML_FILE_TYPES.has(normalizedFileType)) return true;
const candidates = [fileName, path].flatMap((candidate) =>
candidate ? [candidate.toLowerCase()] : [],
);
if (candidates.length === 0) return false;
return candidates.some((candidate) =>
HTML_FILE_EXTENSIONS.some((extension) => candidate.endsWith(extension)),
);
};
+2
View File
@@ -1 +1,3 @@
export { isHtmlFile } from './fileType';
export { default as InlineHtmlPreview } from './InlinePreview';
export { default as HtmlPreviewDrawer } from './PreviewDrawer';
@@ -0,0 +1,43 @@
'use client';
import { Center, Flexbox } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { memo } from 'react';
import { InlineHtmlPreview } from '@/components/HtmlPreview';
import NeuralNetworkLoading from '@/components/NeuralNetworkLoading';
import { useTextFileLoader } from '../../hooks/useTextFileLoader';
const styles = createStaticStyles(({ css }) => ({
page: css`
width: 100%;
height: 100%;
padding: 0;
`,
}));
interface HTMLViewerProps {
fileId: string;
url: string | null;
}
const HTMLViewer = memo<HTMLViewerProps>(({ url }) => {
const { fileData, loading } = useTextFileLoader(url);
return (
<Flexbox className={styles.page}>
{!loading && fileData !== null ? (
<InlineHtmlPreview content={fileData} />
) : (
<Center height={'100%'}>
<NeuralNetworkLoading size={36} />
</Center>
)}
</Flexbox>
);
});
HTMLViewer.displayName = 'HTMLViewer';
export default HTMLViewer;
+8 -1
View File
@@ -1,13 +1,15 @@
'use client';
import { MARKDOWN_MIME_TYPES } from '@lobechat/const';
import { type CSSProperties } from 'react';
import type { CSSProperties } from 'react';
import { memo } from 'react';
import { isHtmlFile } from '@/components/HtmlPreview';
import { type FileListItem } from '@/types/files';
import NotSupport from './NotSupport';
import CodeViewer from './Renderer/Code';
import HTMLViewer from './Renderer/HTML';
import ImageViewer from './Renderer/Image';
import MSDocViewer from './Renderer/MSDoc';
import PDFViewer from './Renderer/PDF';
@@ -267,6 +269,11 @@ const FileViewer = memo<FileViewerProps>(({ id, style, fileType, url, name }) =>
return <MSDocViewer fileId={id} url={url} />;
}
// HTML files should render as a sandboxed preview before the broader code-file fallback.
if (isHtmlFile({ fileName: name, fileType })) {
return <HTMLViewer fileId={id} url={url} />;
}
// Code files (JavaScript, TypeScript, Python, Java, C++, Go, Rust, Markdown, etc.)
if (matchesFileType(fileType, name, CODE_EXTENSIONS, CODE_MIME_TYPES)) {
return <CodeViewer fileId={id} fileName={name} url={url} />;
@@ -1,6 +1,7 @@
import { HtmlPreview } from '@lobehub/ui';
import { memo } from 'react';
import { InlineHtmlPreview } from '@/components/HtmlPreview';
interface HTMLRendererProps {
animated?: boolean;
height?: string;
@@ -8,26 +9,10 @@ interface HTMLRendererProps {
width?: string;
}
const hideHtmlPreviewActions = () => null;
const HTMLRenderer = memo<HTMLRendererProps>(
({ animated, htmlContent, width = '100%', height = '100%' }) => {
return (
<HtmlPreview
actionsRender={hideHtmlPreviewActions}
animated={animated}
copyable={false}
downloadable={false}
shadow={false}
style={{ height, minHeight: 0, overflow: 'hidden', width }}
variant={'borderless'}
styles={{
content: { height: '100%' },
iframe: { height: '100%' },
}}
>
{htmlContent}
</HtmlPreview>
<InlineHtmlPreview animated={animated} content={htmlContent} height={height} width={width} />
);
},
);
+116 -96
View File
@@ -6,6 +6,7 @@ import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import CodeEditorPane from '@/components/CodeEditorPane';
import { InlineHtmlPreview, isHtmlFile } from '@/components/HtmlPreview';
import Loading from '@/components/Loading/CircleLoading';
import { useClientDataSWR } from '@/libs/swr';
import { localFileService } from '@/services/electron/localFileService';
@@ -166,116 +167,134 @@ type TextPreviewMode = 'render' | 'raw';
interface TextPreviewPaneProps {
content: string;
contentType?: string;
ext: string;
filePath: string;
onSaved?: (savedContent: string) => void;
}
const TextPreviewPane = memo<TextPreviewPaneProps>(({ content, ext, filePath, onSaved }) => {
const { t } = useTranslation('chat');
const isMarkdown = useMemo(() => MARKDOWN_EXTS.has(ext.toLowerCase()), [ext]);
const buffer = useChatStore(chatPortalSelectors.localFileBuffer(filePath));
const setLocalFileBuffer = useChatStore((s) => s.setLocalFileBuffer);
const saveLocalFile = useChatStore((s) => s.saveLocalFile);
const TextPreviewPane = memo<TextPreviewPaneProps>(
({ content, contentType, ext, filePath, onSaved }) => {
const { t } = useTranslation('chat');
const isMarkdown = useMemo(() => MARKDOWN_EXTS.has(ext.toLowerCase()), [ext]);
const isHtml = useMemo(
() => isHtmlFile({ fileType: contentType, path: filePath }),
[contentType, filePath],
);
const canRender = isMarkdown || isHtml;
const buffer = useChatStore(chatPortalSelectors.localFileBuffer(filePath));
const setLocalFileBuffer = useChatStore((s) => s.setLocalFileBuffer);
const saveLocalFile = useChatStore((s) => s.saveLocalFile);
const editingValue = buffer ?? content;
const editingValue = buffer ?? content;
const handleCodeChange = useCallback(
(next: string) => {
if (next === content) {
const handleCodeChange = useCallback(
(next: string) => {
if (next === content) {
setLocalFileBuffer(filePath, undefined);
} else {
setLocalFileBuffer(filePath, next);
}
},
[content, filePath, setLocalFileBuffer],
);
const handleSave = useCallback(async () => {
try {
const saved = await saveLocalFile(filePath);
if (saved === undefined) return;
// Update SWR cache BEFORE clearing the buffer, otherwise React will
// briefly render with buffer cleared but content still stale, causing
// CodeMirror to setValue and reset the cursor.
onSaved?.(saved);
setLocalFileBuffer(filePath, undefined);
} else {
setLocalFileBuffer(filePath, next);
} catch {
/* swallow — surfacing handled elsewhere if needed */
}
},
[content, filePath, setLocalFileBuffer],
);
}, [filePath, onSaved, saveLocalFile, setLocalFileBuffer]);
const handleSave = useCallback(async () => {
try {
const saved = await saveLocalFile(filePath);
if (saved === undefined) return;
// Update SWR cache BEFORE clearing the buffer, otherwise React will
// briefly render with buffer cleared but content still stale, causing
// CodeMirror to setValue and reset the cursor.
onSaved?.(saved);
setLocalFileBuffer(filePath, undefined);
} catch {
/* swallow — surfacing handled elsewhere if needed */
}
}, [filePath, onSaved, saveLocalFile, setLocalFileBuffer]);
const { body, frontmatter } = useMemo(
() => (isMarkdown ? parseSkillMarkdownFrontmatter(editingValue) : { body: editingValue }),
[isMarkdown, editingValue],
);
const frontmatterFields = useMemo(
() => (frontmatter ? parseSkillMarkdownFrontmatterFields(frontmatter) : {}),
[frontmatter],
);
const frontmatterMetadata = useMemo(
() => (frontmatter ? parseSkillMarkdownMetadata(frontmatter) : []),
[frontmatter],
);
const previewTitle = isMarkdown
? (frontmatterFields.name ?? '')
: (filePath.split('/').at(-1) ?? filePath);
const { body, frontmatter } = useMemo(
() => (isMarkdown ? parseSkillMarkdownFrontmatter(editingValue) : { body: editingValue }),
[isMarkdown, editingValue],
);
const frontmatterFields = useMemo(
() => (frontmatter ? parseSkillMarkdownFrontmatterFields(frontmatter) : {}),
[frontmatter],
);
const frontmatterMetadata = useMemo(
() => (frontmatter ? parseSkillMarkdownMetadata(frontmatter) : []),
[frontmatter],
);
const [mode, setMode] = useState<TextPreviewMode>(canRender ? 'render' : 'raw');
const showHtmlPreview = isHtml && mode === 'render';
const [mode, setMode] = useState<TextPreviewMode>(isMarkdown ? 'render' : 'raw');
useEffect(() => {
setMode(canRender ? 'render' : 'raw');
}, [canRender, filePath]);
useEffect(() => {
setMode(isMarkdown ? 'render' : 'raw');
}, [isMarkdown]);
return (
<Flexbox flex={1} height={'100%'} style={{ minHeight: 0, overflow: 'hidden' }}>
{isMarkdown && (
<Flexbox
horizontal
align={'center'}
gap={8}
paddingBlock={6}
paddingInline={12}
style={{ flexShrink: 0 }}
>
<Text ellipsis style={{ flex: 1, fontSize: 13, fontWeight: 500, minWidth: 0 }}>
{frontmatterFields.name ?? ''}
</Text>
<Segmented
size={'small'}
value={mode}
options={[
{
icon: <Icon icon={EyeIcon} />,
label: t('workingPanel.localFile.preview.render'),
value: 'render',
},
{
icon: <Icon icon={CodeIcon} />,
label: t('workingPanel.localFile.preview.raw'),
value: 'raw',
},
]}
onChange={(v) => setMode(v as TextPreviewMode)}
/>
</Flexbox>
)}
<div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
{isMarkdown && mode === 'render' ? (
<>
<SkillFrontmatterPreviewCard metadata={frontmatterMetadata} />
<Markdown style={{ paddingBlock: 8, paddingInline: 12 }}>{body}</Markdown>
</>
) : (
<CodeEditorPane
language={extensionToLanguage(ext)}
style={{ fontSize: 12, minHeight: '100%' }}
value={editingValue}
onChange={handleCodeChange}
onSave={handleSave}
/>
return (
<Flexbox flex={1} height={'100%'} style={{ minHeight: 0, overflow: 'hidden' }}>
{canRender && (
<Flexbox
horizontal
align={'center'}
gap={8}
paddingBlock={6}
paddingInline={12}
style={{ flexShrink: 0 }}
>
<Text ellipsis style={{ flex: 1, fontSize: 13, fontWeight: 500, minWidth: 0 }}>
{previewTitle}
</Text>
<Segmented
size={'small'}
value={mode}
options={[
{
icon: <Icon icon={EyeIcon} />,
label: t('workingPanel.localFile.preview.render'),
value: 'render',
},
{
icon: <Icon icon={CodeIcon} />,
label: t(
isHtml
? 'workingPanel.localFile.preview.source'
: 'workingPanel.localFile.preview.raw',
),
value: 'raw',
},
]}
onChange={(v) => setMode(v as TextPreviewMode)}
/>
</Flexbox>
)}
</div>
</Flexbox>
);
});
<div style={{ flex: 1, minHeight: 0, overflow: showHtmlPreview ? 'hidden' : 'auto' }}>
{isMarkdown && mode === 'render' ? (
<>
<SkillFrontmatterPreviewCard metadata={frontmatterMetadata} />
<Markdown style={{ paddingBlock: 8, paddingInline: 12 }}>{body}</Markdown>
</>
) : showHtmlPreview ? (
<InlineHtmlPreview content={editingValue} />
) : (
<CodeEditorPane
language={extensionToLanguage(ext)}
style={{ fontSize: 12, minHeight: '100%' }}
value={editingValue}
onChange={handleCodeChange}
onSave={handleSave}
/>
)}
</div>
</Flexbox>
);
},
);
TextPreviewPane.displayName = 'TextPreviewPane';
@@ -358,6 +377,7 @@ const ActiveFileView = memo<ActiveFileViewProps>(({ filePath, workingDirectory }
return (
<TextPreviewPane
content={preview.content}
contentType={preview.contentType}
ext={ext}
filePath={filePath}
onSaved={handleSavedContent}