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 { Button } from '@/components/ui/button';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
|
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
|
||||||
|
import { PreviewToggleButton } from './PreviewToggleButton';
|
||||||
|
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
|
||||||
import { languageByExtension } from '@/lib/codemirror/languageByExtension';
|
import { languageByExtension } from '@/lib/codemirror/languageByExtension';
|
||||||
import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme';
|
import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme';
|
||||||
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
|
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
|
||||||
@@ -65,6 +67,7 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
|||||||
import { opencodeClient } from '@/lib/opencode/client';
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
|
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
|
||||||
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
|
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
|
||||||
|
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
||||||
|
|
||||||
type FileNode = {
|
type FileNode = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -246,6 +249,12 @@ const getFileIcon = (extension?: string): React.ReactNode => {
|
|||||||
return <RiFileTextLine className="h-4 w-4 flex-shrink-0 text-muted-foreground" />;
|
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 = () => {
|
export const FilesView: React.FC = () => {
|
||||||
const { files, runtime } = useRuntimeAPIs();
|
const { files, runtime } = useRuntimeAPIs();
|
||||||
const { currentTheme } = useThemeSystem();
|
const { currentTheme } = useThemeSystem();
|
||||||
@@ -297,6 +306,9 @@ export const FilesView: React.FC = () => {
|
|||||||
const [copiedContent, setCopiedContent] = React.useState(false);
|
const [copiedContent, setCopiedContent] = React.useState(false);
|
||||||
const [copiedPath, setCopiedPath] = 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 canCreateFile = Boolean(files.writeFile);
|
||||||
const canCreateFolder = Boolean(files.createDirectory);
|
const canCreateFolder = Boolean(files.createDirectory);
|
||||||
const canRename = Boolean(files.rename);
|
const canRename = Boolean(files.rename);
|
||||||
@@ -554,6 +566,38 @@ export const FilesView: React.FC = () => {
|
|||||||
void refreshRoot();
|
void refreshRoot();
|
||||||
}, [currentDirectory, refreshRoot, showGitignored]);
|
}, [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) => {
|
const handleDialogSubmit = React.useCallback(async (e?: React.FormEvent) => {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
if (!dialogData || !activeDialog) return;
|
if (!dialogData || !activeDialog) return;
|
||||||
@@ -1408,10 +1452,17 @@ export const FilesView: React.FC = () => {
|
|||||||
</Button>
|
</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" />
|
<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 && (
|
{canCopy && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
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"
|
className="max-w-full max-h-[70vh] object-contain rounded-md border border-border/30 bg-primary/10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="h-full">
|
||||||
<CodeMirrorEditor
|
<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