/** * In-webview modal for browsing extension logs (forwarded from the host * Logger over the panel message bridge). * * Behaviour: * - Backdrop click is intentionally a no-op (matches the new-issue modal). * - ESC key closes the modal. * - Entries render newest-at-bottom. Auto-scrolls only when the user is * already pinned near the bottom — preserves position when they have * scrolled up to inspect. * - "复制全部" dumps a plain-text rendering to the clipboard. */ import type { ReactElement } from 'react' import type { LogEntry } from '../lib/messages' import { Copy, Trash2, X } from 'lucide-react' import { useEffect, useLayoutEffect, useRef, useState } from 'react' export interface LogModalProps { open: boolean entries: LogEntry[] onClose: () => void onClear: () => void } function pad2(n: number): string { return n < 10 ? `0${n}` : String(n) } function formatTime(ts: number): string { const d = new Date(ts) return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}` } function levelClass(level: LogEntry['level']): string { if (level === 'error') return 'text-[var(--vscode-errorForeground,#f48771)]' if (level === 'warn') return 'text-yellow-400' return 'opacity-80' } function formatPlain(entries: LogEntry[]): string { return entries .map((e) => { const head = `${formatTime(e.ts)} [${e.level.toUpperCase()}] [${e.source}] ${e.message}` return e.details ? `${head}\n ${e.details}` : head }) .join('\n') } export function LogModal({ open, entries, onClose, onClear }: LogModalProps): ReactElement | null { const scrollRef = useRef(null) const pinnedRef = useRef(true) const [copied, setCopied] = useState(false) // ESC to close. useEffect(() => { if (!open) return function onKey(e: KeyboardEvent): void { if (e.key === 'Escape') { e.preventDefault() onClose() } } document.addEventListener('keydown', onKey) return () => document.removeEventListener('keydown', onKey) }, [open, onClose]) // Track whether the user is currently pinned to the bottom (within ~50px). useEffect(() => { const el = scrollRef.current if (!el) return function onScroll(): void { if (!el) return const distance = el.scrollHeight - el.scrollTop - el.clientHeight pinnedRef.current = distance < 50 } el.addEventListener('scroll', onScroll) return () => el.removeEventListener('scroll', onScroll) }, [open]) // Auto-scroll only when the user is already near the bottom. useLayoutEffect(() => { if (!open) return const el = scrollRef.current if (!el) return if (pinnedRef.current) el.scrollTop = el.scrollHeight }, [entries.length, open]) // Reset the "copied" flag whenever the modal opens. useEffect(() => { if (open) setCopied(false) }, [open]) if (!open) return null async function handleCopy(): Promise { try { await navigator.clipboard.writeText(formatPlain(entries)) setCopied(true) window.setTimeout(() => setCopied(false), 2000) } catch { // Clipboard might be unavailable in some VS Code webview contexts — // swallow silently, the user can still read logs on screen. } } return (
{/* Header */}

日志

{entries.length} {' 条'}
{/* Body */}
{entries.length === 0 ? (
暂无日志
) : ( entries.map((e, idx) => ( // ts is monotonic enough at ms resolution for our cap of 500 // entries; fall back to idx if two collide.
{formatTime(e.ts)} {' '} [ {e.level.toUpperCase()} ] {' '} [ {e.source} ] {' '} {e.message}
{e.details && (
{e.details}
)}
)) )}
{/* Footer */}
{copied && ( 已复制 )}
) }