import { useCallback, useEffect, useRef, useState } from 'react'; import { Check, Copy, X, Paperclip } from 'lucide-react'; import { codeToHtml } from 'shiki'; import { sessionEvents } from '@/hooks/sessionEvents'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; interface Props { path: string; content: string; lang: string | null; projectId: string; onClose: () => void; onNavigate: (path: string) => void; } const SHIKI_THEME = 'github-dark'; function splitShikiLines(html: string): string[] { const match = html.match(/]*>([\s\S]*)<\/code>/); if (!match) return []; const inner = match[1]!; const lines = inner.split(/(?=)/); return lines.filter(l => l.trim().length > 0); } function basename(path: string): string { const parts = path.split('/'); return parts[parts.length - 1] ?? path; } export function FileViewerOverlay({ path, content, lang, onClose }: Props) { const [copied, setCopied] = useState(false); const [lineHtmls, setLineHtmls] = useState(null); const [selectedLines, setSelectedLines] = useState>(new Set()); const [showAttachPopover, setShowAttachPopover] = useState(false); const draggingRef = useRef(false); const dragStartRef = useRef(null); const overlayRef = useRef(null); useEffect(() => { setSelectedLines(new Set()); setShowAttachPopover(false); if (!lang) { setLineHtmls(null); return; } let cancelled = false; (async () => { try { const result = await codeToHtml(content, { lang, theme: SHIKI_THEME }); if (!cancelled) { const lines = splitShikiLines(result); setLineHtmls(lines.length > 0 ? lines : null); } } catch { if (!cancelled) setLineHtmls(null); } })(); return () => { cancelled = true; }; }, [content, lang]); const plainLines = content.split('\n'); const totalLines = lineHtmls ? lineHtmls.length : plainLines.length; async function copyAll() { try { await navigator.clipboard.writeText(content); setCopied(true); setTimeout(() => setCopied(false), 1200); } catch { /* ignore */ } } function handleLineMouseDown(lineNo: number, e: React.MouseEvent) { if (e.shiftKey && dragStartRef.current !== null) { const start = dragStartRef.current; const min = Math.min(start, lineNo); const max = Math.max(start, lineNo); const next = new Set(); for (let i = min; i <= max; i++) next.add(i); setSelectedLines(next); setShowAttachPopover(true); return; } draggingRef.current = true; dragStartRef.current = lineNo; setSelectedLines(new Set([lineNo])); setShowAttachPopover(false); } function handleLineMouseEnter(lineNo: number) { if (!draggingRef.current || dragStartRef.current === null) return; const start = dragStartRef.current; const min = Math.min(start, lineNo); const max = Math.max(start, lineNo); const next = new Set(); for (let i = min; i <= max; i++) next.add(i); setSelectedLines(next); } const handleMouseUp = useCallback(() => { if (draggingRef.current) { draggingRef.current = false; if (selectedLines.size > 0) setShowAttachPopover(true); } }, [selectedLines.size]); useEffect(() => { document.addEventListener('mouseup', handleMouseUp); return () => document.removeEventListener('mouseup', handleMouseUp); }, [handleMouseUp]); useEffect(() => { function handleClick(e: MouseEvent) { if (overlayRef.current && !overlayRef.current.contains(e.target as Node)) { onClose(); } } document.addEventListener('mousedown', handleClick); return () => document.removeEventListener('mousedown', handleClick); }, [onClose]); useEffect(() => { function handleKey(e: KeyboardEvent) { if (e.key === 'Escape') onClose(); } document.addEventListener('keydown', handleKey); return () => document.removeEventListener('keydown', handleKey); }, [onClose]); function getSelectionRange(): { min: number; max: number } | null { if (selectedLines.size === 0) return null; let min = Infinity; let max = -Infinity; for (const n of selectedLines) { if (n < min) min = n; if (n > max) max = n; } return { min, max }; } function handleAttach() { const range = getSelectionRange(); if (!range) return; const lines = content.split('\n').slice(range.min - 1, range.max); sessionEvents.emit({ type: 'attach_chat_file', attachment: { kind: 'lines', filename: path, language: lang, content: lines.join('\n'), range: [range.min, range.max], source: 'line-select', }, }); setSelectedLines(new Set()); setShowAttachPopover(false); } const range = getSelectionRange(); const attachLabel = range ? range.min === range.max ? `Attach line ${range.min} to chat` : `Attach lines ${range.min}–${range.max} to chat` : ''; return (
{basename(path)} {path}
{/* Shiki-highlighted code lines are generated from source code files, not user content */}
{Array.from({ length: totalLines }, (_, i) => { const lineNo = i + 1; const isSelected = selectedLines.has(lineNo); return (
handleLineMouseDown(lineNo, e)} onMouseEnter={() => handleLineMouseEnter(lineNo)} >
{lineNo}
{lineHtmls ? (
) : ( {plainLines[i] ?? ''} )}
); })}
{showAttachPopover && range && (
{attachLabel}
)}
); }