Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 52 additions & 2 deletions frontend/src/components/file-browser/FilePreview.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { useState, useCallback, useRef, useEffect, memo } from 'react'
import { Button } from '@/components/ui/button'
import { Download, X, Edit3, Save, X as XIcon, WrapText } from 'lucide-react'
import { Download, X, Edit3, Save, X as XIcon, WrapText, Eye, Code } from 'lucide-react'
import type { FileInfo } from '@/types/files'
import { API_BASE_URL } from '@/config'
import { VirtualizedTextView, type VirtualizedTextViewHandle } from '@/components/ui/virtualized-text-view'
import { MarkdownRenderer } from './MarkdownRenderer'


const API_BASE = API_BASE_URL
Expand All @@ -20,18 +21,38 @@ interface FilePreviewProps {
}

export const FilePreview = memo(function FilePreview({ file, hideHeader = false, isMobileModal = false, onCloseModal, onFileSaved, initialLineNumber }: FilePreviewProps) {
const isMarkdownFile = file.name.endsWith('.md') || file.name.endsWith('.mdx') || file.mimeType === 'text/markdown'

const [viewMode, setViewMode] = useState<'preview' | 'edit'>('preview')
const [editContent, setEditContent] = useState('')
const [isSaving, setIsSaving] = useState(false)
const [hasVirtualizedChanges, setHasVirtualizedChanges] = useState(false)
const [highlightedLine, setHighlightedLine] = useState<number | undefined>(initialLineNumber)
const [lineWrap, setLineWrap] = useState(true)
const [markdownPreview, setMarkdownPreview] = useState(isMarkdownFile)
const [fullMarkdownContent, setFullMarkdownContent] = useState<string | null>(null)
const [isLoadingFullContent, setIsLoadingFullContent] = useState(false)
const virtualizedRef = useRef<VirtualizedTextViewHandle>(null)
const contentRef = useRef<HTMLDivElement>(null)


const shouldVirtualize = file.size > VIRTUALIZATION_THRESHOLD_BYTES && !file.mimeType?.startsWith('image/')

useEffect(() => {
if (shouldVirtualize && isMarkdownFile && markdownPreview && !fullMarkdownContent) {
setIsLoadingFullContent(true)
fetch(`${API_BASE}/api/files/${file.path}?raw=true`)
.then(res => res.text())
.then(content => {
setFullMarkdownContent(content)
setIsLoadingFullContent(false)
})
.catch(() => {
setIsLoadingFullContent(false)
})
}
}, [shouldVirtualize, isMarkdownFile, markdownPreview, fullMarkdownContent, file.path])



useEffect(() => {
Expand Down Expand Up @@ -165,6 +186,18 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false,
}

if (shouldVirtualize && isTextFile) {
if (isMarkdownFile && markdownPreview && viewMode !== 'edit') {
if (isLoadingFullContent) {
return (
<div className="flex items-center justify-center h-32">
<div className="w-6 h-6 border-2 border-muted-foreground border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (fullMarkdownContent) {
return <MarkdownRenderer content={fullMarkdownContent} />
}
}
return (
<VirtualizedTextView
ref={virtualizedRef}
Expand Down Expand Up @@ -208,6 +241,11 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false,
</div>
)
}

if (isMarkdownFile && markdownPreview) {
return <MarkdownRenderer content={textContent} />
}

const lines = textContent.split('\n')
return (
<div className={`pb-[200px] text-sm bg-muted text-foreground rounded font-mono ${
Expand Down Expand Up @@ -288,7 +326,19 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false,
</div>

<div className="flex items-center gap-1 flex-shrink-0 mt-1">
{isTextFile && (
{isMarkdownFile && viewMode !== 'edit' && (
<Button
variant="outline"
size="sm"
onClick={(e) => { e.stopPropagation(); e.preventDefault(); setMarkdownPreview(!markdownPreview) }}
className={`h-7 w-7 p-0 ${markdownPreview ? 'bg-primary text-primary-foreground' : ''}`}
title={markdownPreview ? "Show raw markdown" : "Preview rendered markdown"}
>
{markdownPreview ? <Code className="w-3 h-3" /> : <Eye className="w-3 h-3" />}
</Button>
)}

{isTextFile && !markdownPreview && (
<Button
variant="outline"
size="sm"
Expand Down
44 changes: 44 additions & 0 deletions frontend/src/components/file-browser/MarkdownComponents.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { Components } from 'react-markdown'

export const markdownComponents: Components = {
code({ className, children, ...props }) {
const isInline = !className || !className.includes('language-')
if (isInline) {
return (
<code className={className || "bg-accent px-1.5 py-0.5 rounded text-sm text-foreground break-all"} {...props}>
{children}
</code>
)
}
return <code className={className} {...props}>{children}</code>
},
pre({ children }) {
return (
<pre className="bg-accent p-1 rounded-lg overflow-x-auto whitespace-pre-wrap break-words border border-border my-4">
{children}
</pre>
)
},
p({ children }) {
return <p className="text-foreground my-0.5 md:my-1">{children}</p>
},
strong({ children }) {
return <strong className="font-semibold text-foreground">{children}</strong>
},
ul({ children }) {
return <ul className="list-disc text-foreground my-0.5 md:my-1">{children}</ul>
},
ol({ children }) {
return <ol className="list-decimal text-foreground my-0.5 md:my-1">{children}</ol>
},
li({ children }) {
return <li className="text-foreground my-0.5 md:my-1">{children}</li>
},
table({ children }) {
return (
<div className="table-wrapper">
<table>{children}</table>
</div>
)
}
}
25 changes: 25 additions & 0 deletions frontend/src/components/file-browser/MarkdownRenderer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { memo } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
import rehypeRaw from 'rehype-raw'
import { markdownComponents } from './MarkdownComponents'

interface MarkdownRendererProps {
content: string
className?: string
}

export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className = '' }: MarkdownRendererProps) {
return (
<div className={`pb-[200px] p-4 prose prose-invert prose-enhanced max-w-none text-foreground overflow-hidden break-words leading-snug ${className}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight, rehypeRaw]}
components={markdownComponents}
>
{content}
</ReactMarkdown>
</div>
)
})
32 changes: 32 additions & 0 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -308,3 +308,35 @@ body {
font-size: 16px !important;
}
}

/* Syntax highlighting for markdown code blocks */
@import 'highlight.js/styles/github.css' layer(highlight-light);
@import 'highlight.js/styles/github-dark.css' layer(highlight-dark);

@layer highlight-light {
:root:not(.dark) .hljs {
display: block;
overflow-x: auto;
padding: 0.5em;
background: var(--color-accent);
color: var(--color-foreground);
}
}

@layer highlight-dark {
.dark .hljs {
display: block;
overflow-x: auto;
padding: 0.5em;
background: var(--color-accent);
color: var(--color-foreground);
}
}

:root:not(.dark) .hljs-dark {
display: none;
}

.dark .hljs:not(.hljs-dark) {
display: none;
}