feat(ui): add markdown preview toggle for FilesView (#224)
This commit is contained in:
@@ -38,6 +38,8 @@ import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
|
||||
import { PreviewToggleButton } from './PreviewToggleButton';
|
||||
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
|
||||
import { languageByExtension } from '@/lib/codemirror/languageByExtension';
|
||||
import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme';
|
||||
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
|
||||
@@ -65,6 +67,7 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
|
||||
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
|
||||
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
||||
|
||||
type FileNode = {
|
||||
name: string;
|
||||
@@ -230,7 +233,7 @@ const DOCUMENT_EXTENSIONS = new Set([
|
||||
|
||||
const getFileIcon = (extension?: string): React.ReactNode => {
|
||||
const ext = extension?.toLowerCase();
|
||||
|
||||
|
||||
if (ext && CODE_EXTENSIONS.has(ext)) {
|
||||
return <RiCodeLine className="h-4 w-4 flex-shrink-0 text-blue-500" />;
|
||||
}
|
||||
@@ -246,6 +249,12 @@ const getFileIcon = (extension?: string): React.ReactNode => {
|
||||
return <RiFileTextLine className="h-4 w-4 flex-shrink-0 text-muted-foreground" />;
|
||||
};
|
||||
|
||||
const isMarkdownFile = (path: string): boolean => {
|
||||
if (!path) return false;
|
||||
const ext = path.toLowerCase().split('.').pop();
|
||||
return ext === 'md' || ext === 'markdown';
|
||||
};
|
||||
|
||||
export const FilesView: React.FC = () => {
|
||||
const { files, runtime } = useRuntimeAPIs();
|
||||
const { currentTheme } = useThemeSystem();
|
||||
@@ -297,6 +306,9 @@ export const FilesView: React.FC = () => {
|
||||
const [copiedContent, setCopiedContent] = React.useState(false);
|
||||
const [copiedPath, setCopiedPath] = React.useState(false);
|
||||
|
||||
// Markdown view mode (global, not per-file)
|
||||
const [mdViewMode, setMdViewMode] = React.useState<'preview' | 'edit'>('edit');
|
||||
|
||||
const canCreateFile = Boolean(files.writeFile);
|
||||
const canCreateFolder = Boolean(files.createDirectory);
|
||||
const canRename = Boolean(files.rename);
|
||||
@@ -554,6 +566,38 @@ export const FilesView: React.FC = () => {
|
||||
void refreshRoot();
|
||||
}, [currentDirectory, refreshRoot, showGitignored]);
|
||||
|
||||
const MD_VIEWER_MODE_KEY = 'openchamber:files:md-viewer-mode';
|
||||
|
||||
// Load markdown view mode preference from localStorage on mount
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(MD_VIEWER_MODE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (parsed === 'preview' || parsed === 'edit') {
|
||||
setMdViewMode(parsed);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save markdown view mode preference to localStorage
|
||||
const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => {
|
||||
setMdViewMode(mode);
|
||||
try {
|
||||
localStorage.setItem(MD_VIEWER_MODE_KEY, JSON.stringify(mode));
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Get the view mode for a markdown file (from state, default to 'edit')
|
||||
const getMdViewMode = React.useCallback((): 'preview' | 'edit' => {
|
||||
return mdViewMode;
|
||||
}, [mdViewMode]);
|
||||
|
||||
const handleDialogSubmit = React.useCallback(async (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
if (!dialogData || !activeDialog) return;
|
||||
@@ -1408,10 +1452,17 @@ export const FilesView: React.FC = () => {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{(canCopy || canCopyPath) && (canEdit || (selectedFile && !isSelectedImage)) && (
|
||||
{(canCopy || canCopyPath || (selectedFile && isMarkdownFile(selectedFile.path))) && (canEdit || (selectedFile && !isSelectedImage)) && (
|
||||
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
|
||||
)}
|
||||
|
||||
{selectedFile && isMarkdownFile(selectedFile.path) && (
|
||||
<PreviewToggleButton
|
||||
currentMode={getMdViewMode()}
|
||||
onToggle={() => saveMdViewMode(getMdViewMode() === 'preview' ? 'edit' : 'preview')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canCopy && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -1493,6 +1544,26 @@ export const FilesView: React.FC = () => {
|
||||
className="max-w-full max-h-[70vh] object-contain rounded-md border border-border/30 bg-primary/10"
|
||||
/>
|
||||
</div>
|
||||
) : selectedFile && isMarkdownFile(selectedFile.path) && getMdViewMode() === 'preview' ? (
|
||||
<div className="h-full overflow-auto p-3">
|
||||
{fileContent.length > 500 * 1024 && (
|
||||
<div className="mb-3 rounded-md border border-warning/20 bg-warning/10 px-3 py-2 text-sm text-warning">
|
||||
⚠️ This file is large ({Math.round(fileContent.length / 1024)}KB). Preview may be limited.
|
||||
</div>
|
||||
)}
|
||||
<ErrorBoundary
|
||||
fallback={
|
||||
<div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2">
|
||||
<div className="mb-1 font-medium text-destructive">Preview unavailable</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Switch to edit mode to fix the issue.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SimpleMarkdownRenderer content={fileContent} className="typography-markdown-body" />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full">
|
||||
<CodeMirrorEditor
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
|
||||
import { RiEyeLine, RiEyeOffLine } from '@remixicon/react';
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export type PreviewToggleButtonProps = {
|
||||
/** Current mode - determines which icon is displayed */
|
||||
currentMode: 'preview' | 'edit';
|
||||
/** Callback fired when toggle button is clicked */
|
||||
onToggle: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* PreviewToggleButton - A toggle button for switching between preview and edit modes.
|
||||
*
|
||||
* Displays an eye icon when in preview mode (indicating the content is visible/read-only)
|
||||
* and a slashed eye icon when in edit mode (indicating the content can be edited).
|
||||
*/
|
||||
export const PreviewToggleButton: React.FC<PreviewToggleButtonProps> = ({
|
||||
currentMode,
|
||||
onToggle,
|
||||
}) => {
|
||||
const isPreview = currentMode === 'preview';
|
||||
const ariaLabel = isPreview ? 'Switch to edit mode' : 'Switch to preview mode';
|
||||
const tooltipText = isPreview ? 'Edit' : 'Preview';
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onToggle}
|
||||
aria-label={ariaLabel}
|
||||
className="size-8"
|
||||
>
|
||||
{isPreview ? (
|
||||
<RiEyeLine className="size-4" aria-hidden="true" />
|
||||
) : (
|
||||
<RiEyeOffLine className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8}>
|
||||
{tooltipText}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user