216 lines
6.9 KiB
TypeScript
216 lines
6.9 KiB
TypeScript
/**
|
|
* 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<HTMLDivElement | null>(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<void> {
|
|
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 (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
className="relative flex h-[min(600px,80vh)] w-[min(900px,90vw)] flex-col rounded-md border border-[var(--vscode-panel-border)] bg-[var(--vscode-editor-background)] shadow-xl"
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center gap-2 border-b border-[var(--vscode-panel-border)] px-4 py-2">
|
|
<h2 className="text-sm font-medium">日志</h2>
|
|
<span className="text-xs opacity-60">
|
|
{entries.length}
|
|
{' 条'}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
aria-label="关闭"
|
|
className="ml-auto grid size-6 place-items-center rounded opacity-60 hover:opacity-100"
|
|
>
|
|
<X className="size-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div
|
|
ref={scrollRef}
|
|
className="min-h-0 flex-1 overflow-y-auto px-3 py-2 font-mono text-xs"
|
|
>
|
|
{entries.length === 0
|
|
? (
|
|
<div className="flex h-full items-center justify-center text-xs opacity-50">
|
|
暂无日志
|
|
</div>
|
|
)
|
|
: (
|
|
entries.map((e, idx) => (
|
|
// ts is monotonic enough at ms resolution for our cap of 500
|
|
// entries; fall back to idx if two collide.
|
|
<div key={`${e.ts}-${idx}`} className="whitespace-pre-wrap py-0.5 leading-snug">
|
|
<div>
|
|
<span className="opacity-60">{formatTime(e.ts)}</span>
|
|
{' '}
|
|
<span className={levelClass(e.level)}>
|
|
[
|
|
{e.level.toUpperCase()}
|
|
]
|
|
</span>
|
|
{' '}
|
|
<span className="opacity-70">
|
|
[
|
|
{e.source}
|
|
]
|
|
</span>
|
|
{' '}
|
|
<span>{e.message}</span>
|
|
</div>
|
|
{e.details && (
|
|
<div className="pl-6 opacity-60">{e.details}</div>
|
|
)}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="flex items-center gap-2 border-t border-[var(--vscode-panel-border)] px-4 py-2">
|
|
<button
|
|
type="button"
|
|
onClick={onClear}
|
|
className="inline-flex items-center gap-1.5 rounded border border-[var(--vscode-panel-border)] px-2 py-1 text-xs hover:border-[var(--vscode-focusBorder)]"
|
|
>
|
|
<Trash2 className="size-4" />
|
|
清空
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleCopy}
|
|
className="inline-flex items-center gap-1.5 rounded border border-[var(--vscode-panel-border)] px-2 py-1 text-xs hover:border-[var(--vscode-focusBorder)]"
|
|
>
|
|
<Copy className="size-4" />
|
|
复制全部
|
|
</button>
|
|
{copied && (
|
|
<span className="text-xs opacity-70">已复制</span>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="ml-auto inline-flex items-center gap-1.5 rounded bg-[var(--vscode-button-background)] px-3 py-1 text-xs text-[var(--vscode-button-foreground)] hover:bg-[var(--vscode-button-hoverBackground)]"
|
|
>
|
|
关闭
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|