Files
hermes-agent/apps/desktop/src/app/chat/right-rail/preview-file.tsx
T

554 lines
17 KiB
TypeScript

import type * as React from 'react'
import type {
ComponentProps,
CSSProperties,
DragEvent as ReactDragEvent,
MouseEvent as ReactMouseEvent,
ReactNode
} from 'react'
import { useEffect, useMemo, useState } from 'react'
import ShikiHighlighter from 'react-shiki'
import { Streamdown } from 'streamdown'
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
import { cn } from '@/lib/utils'
import type { PreviewTarget } from '@/store/preview'
const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const
const TEXT_PREVIEW_MAX_BYTES = 512 * 1024
type EmptyStateTone = 'neutral' | 'warning'
const TONE_STYLES: Record<EmptyStateTone, { cube: string; primary: string }> = {
neutral: {
cube: 'text-muted-foreground/35',
primary: 'border-border bg-background text-foreground hover:bg-accent'
},
warning: {
cube: 'text-amber-500/70 dark:text-amber-300/70',
primary:
'border-amber-400/40 bg-amber-50 text-amber-900 hover:bg-amber-100 dark:border-amber-300/30 dark:bg-amber-300/15 dark:text-amber-100 dark:hover:bg-amber-300/20'
}
}
function PreviewCubeIcon({ className }: { className?: string }) {
return (
<svg aria-hidden="true" className={cn('size-16', className)} viewBox="0 0 64 64">
<path
d="M32 5 56 18.5v27L32 59 8 45.5v-27L32 5Z"
fill="none"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="1.25"
/>
<path
d="M8 18.5 32 32l24-13.5M32 32v27"
fill="none"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="1.25"
/>
<path d="M20 11.75 44 25.25" fill="none" opacity="0.45" stroke="currentColor" strokeWidth="0.9" />
</svg>
)
}
interface PreviewEmptyStateProps {
body?: ReactNode
consoleHeight?: number
primaryAction?: { disabled?: boolean; label: string; onClick: () => void }
secondaryAction?: { disabled?: boolean; label: string; onClick: () => void }
title: string
tone?: EmptyStateTone
}
export function PreviewEmptyState({
body,
consoleHeight = 0,
primaryAction,
secondaryAction,
title,
tone = 'neutral'
}: PreviewEmptyStateProps) {
const styles = TONE_STYLES[tone]
return (
<div
className="absolute inset-x-0 top-0 z-10 grid place-items-center bg-background px-8 py-10 text-center bottom-(--preview-error-bottom)"
style={{ '--preview-error-bottom': `${consoleHeight}px` } as CSSProperties}
>
<div className="grid max-w-sm justify-items-center gap-5">
<PreviewCubeIcon className={styles.cube} />
<div className="grid gap-2">
<div className="text-sm font-medium text-foreground">{title}</div>
{body && <div className="text-xs leading-relaxed text-muted-foreground">{body}</div>}
</div>
{(primaryAction || secondaryAction) && (
<div className="grid justify-items-center gap-2">
{primaryAction && (
<button
className={cn(
'rounded-full border px-3.5 py-1.5 text-xs font-medium shadow-xs transition-colors disabled:cursor-default disabled:opacity-60',
styles.primary
)}
disabled={primaryAction.disabled}
onClick={primaryAction.onClick}
type="button"
>
{primaryAction.label}
</button>
)}
{secondaryAction && (
<button
className="text-[0.6875rem] font-medium text-muted-foreground underline decoration-muted-foreground/25 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/55 disabled:cursor-default disabled:text-muted-foreground/55 disabled:no-underline"
disabled={secondaryAction.disabled}
onClick={secondaryAction.onClick}
type="button"
>
{secondaryAction.label}
</button>
)}
</div>
)}
</div>
</div>
)
}
interface LocalPreviewState {
binary?: boolean
byteSize?: number
dataUrl?: string
error?: string
language?: string
loading: boolean
text?: string
truncated?: boolean
}
function filePathForTarget(target: PreviewTarget) {
if (target.path) {
return target.path
}
try {
const url = new URL(target.url)
return url.protocol === 'file:' ? decodeURIComponent(url.pathname) : target.url
} catch {
return target.url
}
}
function formatBytes(bytes: number | undefined) {
if (!bytes) {
return 'unknown size'
}
const units = ['B', 'KB', 'MB', 'GB']
let value = bytes
let unit = 0
while (value >= 1024 && unit < units.length - 1) {
value /= 1024
unit += 1
}
return `${value >= 10 || unit === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}`
}
function looksBinaryBytes(bytes: Uint8Array) {
if (!bytes.length) {
return false
}
let suspicious = 0
for (const byte of bytes.slice(0, 4096)) {
if (byte === 0) {
return true
}
if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) {
suspicious += 1
}
}
return suspicious / Math.min(bytes.length, 4096) > 0.12
}
async function readTextPreview(filePath: string) {
if (window.hermesDesktop.readFileText) {
try {
return await window.hermesDesktop.readFileText(filePath)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (!message.includes("No handler registered for 'hermes:readFileText'")) {
throw error
}
}
}
// Back-compat for a running Electron process whose preload hasn't been
// restarted since readFileText was added. readFileDataUrl already existed.
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath)
const [, metadata = '', data = ''] = dataUrl.match(/^data:([^,]*),(.*)$/) || []
const base64 = metadata.includes(';base64')
const mimeType = metadata.replace(/;base64$/, '') || undefined
const raw = base64 ? atob(data) : decodeURIComponent(data)
const bytes = Uint8Array.from(raw, ch => ch.charCodeAt(0))
return {
binary: looksBinaryBytes(bytes),
byteSize: bytes.byteLength,
mimeType,
path: filePath,
text: new TextDecoder().decode(bytes)
}
}
// Lightweight markdown renderer for file previews. Streamdown does the parse;
// our components keep typography simple and route fenced code through Shiki
// without the library's copy/download/fullscreen chrome.
const MD_TAG_CLASSES = {
h1: 'mb-3 mt-6 text-3xl font-bold leading-tight tracking-tight first:mt-0',
h2: 'mb-2.5 mt-5 text-2xl font-semibold leading-snug tracking-tight first:mt-0',
h3: 'mb-2 mt-4 text-xl font-semibold leading-snug first:mt-0',
h4: 'mb-2 mt-3 text-base font-semibold leading-snug first:mt-0',
p: 'mb-4 leading-relaxed text-foreground last:mb-0',
ul: 'mb-4 list-disc pl-6 marker:text-muted-foreground/70 last:mb-0',
ol: 'mb-4 list-decimal pl-6 marker:text-muted-foreground/70 last:mb-0',
li: 'mt-1 leading-relaxed',
blockquote: 'mb-4 border-l-2 border-border pl-3 text-muted-foreground italic last:mb-0',
pre: 'mb-4 overflow-hidden rounded-lg border border-border bg-card font-mono text-xs leading-relaxed last:mb-0 [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:p-3 [&_pre]:font-mono'
} as const
function tagged<T extends keyof typeof MD_TAG_CLASSES>(Tag: T) {
const base = MD_TAG_CLASSES[Tag]
const Component = (({ className, ...rest }: ComponentProps<T>) => {
const Element = Tag as React.ElementType
return <Element className={cn(base, className)} {...rest} />
}) as React.FC<ComponentProps<T>>
Component.displayName = `Md.${Tag}`
return Component
}
function MarkdownCode({ className, children, ...props }: ComponentProps<'code'>) {
const language = /language-([^\s]+)/.exec(className || '')?.[1]
if (!language) {
return (
<code
className={cn(
'rounded bg-muted px-1 py-0.5 font-mono text-[0.86em] text-pink-700 dark:text-pink-300',
className
)}
{...props}
>
{children}
</code>
)
}
return (
<ShikiHighlighter
addDefaultStyles={false}
as="div"
defaultColor="light-dark()"
delay={80}
language={language}
showLanguage={false}
theme={SHIKI_THEME}
>
{String(children).replace(/\n$/, '')}
</ShikiHighlighter>
)
}
const MARKDOWN_COMPONENTS = {
h1: tagged('h1'),
h2: tagged('h2'),
h3: tagged('h3'),
h4: tagged('h4'),
p: tagged('p'),
ul: tagged('ul'),
ol: tagged('ol'),
li: tagged('li'),
blockquote: tagged('blockquote'),
pre: tagged('pre'),
code: MarkdownCode
}
function MarkdownPreview({ text }: { text: string }) {
return (
<div className="preview-markdown mx-auto max-w-3xl px-4 py-3 text-sm text-foreground">
<Streamdown components={MARKDOWN_COMPONENTS} controls={false} mode="static" parseIncompleteMarkdown={false}>
{text}
</Streamdown>
</div>
)
}
function PreviewToggle({ asSource, onToggle }: { asSource: boolean; onToggle: () => void }) {
return (
<div className="sticky top-0 z-10 flex justify-end border-b border-border/40 bg-transparent px-3 py-1 backdrop-blur">
<button
className="text-[0.625rem] font-bold text-muted-foreground underline decoration-muted-foreground/25 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/55"
onClick={onToggle}
type="button"
>
{asSource ? 'PREVIEW' : 'SOURCE'}
</button>
</div>
)
}
// Gutter and Shiki output share `font-mono text-xs leading-relaxed py-3` so
// each line aligns vertically. The selection overlay relies on the same
// `text-xs * leading-relaxed = 1.21875rem` line-height to position itself.
const SOURCE_LINE_HEIGHT_REM = 1.21875
const SOURCE_PAD_Y_REM = 0.75
interface LineSelection {
end: number
start: number
}
function startLineDrag(event: ReactDragEvent<HTMLElement>, filePath: string, { end, start }: LineSelection) {
const lineEnd = end > start ? end : undefined
const label = lineEnd ? `${filePath}:${start}-${end}` : `${filePath}:${start}`
event.dataTransfer.setData(HERMES_PATHS_MIME, JSON.stringify([{ line: start, lineEnd, path: filePath }]))
event.dataTransfer.setData('text/plain', label)
event.dataTransfer.effectAllowed = 'copy'
}
function SourceView({ filePath, language, text }: { filePath: string; language: string; text: string }) {
const lineCount = useMemo(() => Math.max(1, text.split('\n').length), [text])
const [selection, setSelection] = useState<LineSelection | null>(null)
const inSelection = (line: number) => selection != null && line >= selection.start && line <= selection.end
const handleLineClick = (event: ReactMouseEvent, line: number) => {
if (event.shiftKey && selection) {
setSelection({ end: Math.max(selection.end, line), start: Math.min(selection.start, line) })
return
}
if (selection?.start === line && selection.end === line) {
setSelection(null)
return
}
setSelection({ end: line, start: line })
}
const handleDragStart = (event: ReactDragEvent<HTMLElement>, line: number) => {
startLineDrag(event, filePath, inSelection(line) && selection ? selection : { end: line, start: line })
}
return (
<div className="grid min-w-max grid-cols-[auto_minmax(0,1fr)] font-mono text-xs leading-relaxed">
<div className="select-none py-3 text-right text-muted-foreground/55">
{Array.from({ length: lineCount }, (_, index) => {
const line = index + 1
const selected = inSelection(line)
return (
<div
className={cn(
'cursor-pointer px-3 tabular-nums transition-colors',
selected
? 'bg-amber-200/45 text-amber-900 dark:bg-amber-300/20 dark:text-amber-100'
: 'hover:text-foreground'
)}
draggable
key={line}
onClick={event => handleLineClick(event, line)}
onDragStart={event => handleDragStart(event, line)}
title="Click to select · shift-click to extend · drag to composer"
>
{line}
</div>
)
})}
</div>
<div className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3 [&_pre]:bg-transparent!">
{selection && (
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bg-amber-200/35 dark:bg-amber-300/10"
style={{
top: `calc(${SOURCE_PAD_Y_REM}rem + ${selection.start - 1} * ${SOURCE_LINE_HEIGHT_REM}rem)`,
height: `calc(${selection.end - selection.start + 1} * ${SOURCE_LINE_HEIGHT_REM}rem)`
}}
/>
)}
<ShikiHighlighter
addDefaultStyles={false}
as="div"
defaultColor="light-dark()"
delay={80}
language={language || 'text'}
showLanguage={false}
theme={SHIKI_THEME}
>
{text}
</ShikiHighlighter>
</div>
</div>
)
}
export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: PreviewTarget }) {
const [state, setState] = useState<LocalPreviewState>({ loading: true })
const [forcePreview, setForcePreview] = useState(false)
const [renderMarkdownAsSource, setRenderMarkdownAsSource] = useState(false)
const filePath = filePathForTarget(target)
const isImage = target.previewKind === 'image'
// HTML files are rendered as source code, not in a webview - so they take
// the same path as plain text files. `previewKind === 'binary'` arrives
// when the file is forcibly previewed past the binary refusal screen.
const isText = target.previewKind === 'text' || target.previewKind === 'binary' || target.previewKind === 'html'
const blockedByTarget = !isImage && !forcePreview && (target.binary || target.large)
useEffect(() => {
let active = true
async function load() {
if (blockedByTarget) {
setState({ loading: false })
return
}
if (!isImage && !isText) {
setState({ loading: false })
return
}
setState({ loading: true })
try {
if (isImage) {
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath)
if (active) {
setState({ dataUrl, loading: false })
}
return
}
const result = await readTextPreview(filePath)
if (active) {
const shouldBlock = !forcePreview && (result.binary || (result.byteSize ?? 0) > TEXT_PREVIEW_MAX_BYTES)
setState({
binary: result.binary,
byteSize: result.byteSize,
language: result.language || target.language || 'text',
loading: false,
text: shouldBlock ? undefined : result.text,
truncated: result.truncated
})
}
} catch (error) {
if (active) {
setState({
error: error instanceof Error ? error.message : String(error),
loading: false
})
}
}
}
void load()
return () => {
active = false
}
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.language])
if (state.loading) {
return <div className="grid h-full place-items-center text-xs text-muted-foreground">Loading preview</div>
}
if (state.error) {
return <PreviewEmptyState body={state.error} title="Preview unavailable" />
}
if (
!isImage &&
!forcePreview &&
(target.binary || target.large || state.binary || (state.byteSize ?? 0) > TEXT_PREVIEW_MAX_BYTES)
) {
const binary = target.binary || state.binary
const size = target.byteSize || state.byteSize
return (
<PreviewEmptyState
body={
binary
? `Previewing ${target.label} may show unreadable text.`
: `${target.label} is ${formatBytes(size)}. Hermes will only show the first 512 KB.`
}
primaryAction={{ label: 'Preview anyway', onClick: () => setForcePreview(true) }}
title={binary ? 'This looks like a binary file' : 'This file is large'}
tone="warning"
/>
)
}
if (isImage && state.dataUrl) {
return (
<div className="flex h-full w-full items-center justify-center overflow-auto bg-transparent p-4">
<img
alt={target.label}
className="max-h-full max-w-full rounded-lg object-contain shadow-sm"
draggable={false}
src={state.dataUrl}
/>
</div>
)
}
if (isText && state.text !== undefined) {
const isMarkdown = (state.language || target.language) === 'markdown'
const showRendered = isMarkdown && !renderMarkdownAsSource
return (
<div className="h-full overflow-auto bg-transparent">
{state.truncated && (
<div className="border-b border-border/60 bg-muted/35 px-3 py-1.5 text-[0.68rem] text-muted-foreground">
Showing first 512 KB.
</div>
)}
{isMarkdown && <PreviewToggle asSource={!showRendered} onToggle={() => setRenderMarkdownAsSource(s => !s)} />}
{showRendered ? (
<MarkdownPreview text={state.text} />
) : (
<SourceView filePath={filePath} language={state.language || 'text'} text={state.text} />
)}
</div>
)
}
return (
<PreviewEmptyState
body={`${target.mimeType || 'This file type'} can still be attached as context.`}
title="No inline preview"
/>
)
}