Files
wild-pc/app/src/components/LogViewer.tsx
Paul Payne 38230c0d12 Stop new log lines from jumping the whole page
The auto-scroll effect used bottomRef.scrollIntoView(), which walks the
entire scroll-ancestor chain up to the window and scrolls the page to the
log's bottom on every new line. Scroll the <pre> container directly via
scrollTop instead, so only the log pane moves. Drop the now-unused
bottom marker div and ref.
2026-06-30 23:10:47 -07:00

138 lines
4.7 KiB
TypeScript

import { useEffect, useImperativeHandle, useRef, useState, forwardRef } from "react"
import { Maximize2, Minimize2, Pause, Play, Trash2 } from "lucide-react"
import { apiClient } from "@/services/api/client"
interface LogViewerProps {
name: string
lines?: number
follow?: boolean
}
export interface LogViewerHandle {
clear: () => void
}
export const LogViewer = forwardRef<LogViewerHandle, LogViewerProps>(function LogViewer(
{ name, lines = 50, follow = true },
ref,
) {
const [logs, setLogs] = useState<string[]>([])
const [connected, setConnected] = useState(false)
const [maximized, setMaximized] = useState(false)
// When true, new log lines scroll the view to the bottom. Scrolling up to read
// pauses it; scrolling back to the bottom (or the play button) resumes.
const [autoScroll, setAutoScroll] = useState(true)
const preRef = useRef<HTMLPreElement>(null)
useEffect(() => {
if (!follow) {
apiClient
.get<{ lines: string[] }>(`/logs/${name}?n=${lines}`)
.then((data) => setLogs(data.lines))
return
}
const url = apiClient.streamUrl(`/logs/${name}?n=${lines}&follow=true`)
const es = new EventSource(url)
es.onopen = () => setConnected(true)
es.onmessage = (e) => {
setLogs((prev) => {
const next = [...prev, e.data]
return next.length > 500 ? next.slice(-500) : next
})
}
es.onerror = () => setConnected(false)
return () => es.close()
}, [name, lines, follow])
const clear = () => setLogs([])
useImperativeHandle(ref, () => ({ clear }), [])
// Esc exits fullscreen.
useEffect(() => {
if (!maximized) return
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setMaximized(false)
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [maximized])
// Jump to the newest line only while auto-scroll is on. Scroll the <pre>
// container directly (not scrollIntoView, which would walk up to the window
// and jump the whole page). Instant, so the scroll-position check below never
// sees a mid-animation false "scrolled up".
useEffect(() => {
const el = preRef.current
if (autoScroll && el) el.scrollTop = el.scrollHeight
}, [logs, autoScroll])
// Keep auto-scroll in sync with where the user is: away from the bottom pauses,
// back at the bottom resumes — so the button and the scroll position never disagree.
const onScroll = () => {
const el = preRef.current
if (!el) return
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40
setAutoScroll(atBottom)
}
const btn =
"p-1 rounded text-[var(--muted)] hover:text-[var(--foreground)] hover:bg-white/10 transition-colors"
return (
<div
className={
maximized
? "fixed inset-0 z-50 bg-[var(--background)] flex flex-col"
: "bg-black/40 border border-[var(--border)] rounded-lg overflow-hidden"
}
>
<div className="flex items-center justify-between px-3 py-1.5 border-b border-[var(--border)] text-xs text-[var(--muted)]">
<span className="flex items-center gap-2">
<span>logs: {name}</span>
{follow && (
<span className={connected ? "text-green-400" : "text-red-400"}>
{connected ? "streaming" : "disconnected"}
</span>
)}
{follow && !autoScroll && <span className="text-amber-400">paused</span>}
</span>
<span className="flex items-center gap-0.5">
<button
onClick={() => setAutoScroll((s) => !s)}
className={btn}
title={autoScroll ? "Pause auto-scroll" : "Resume auto-scroll (jump to latest)"}
>
{autoScroll ? <Pause size={13} /> : <Play size={13} />}
</button>
<button onClick={clear} className={btn} title="Clear">
<Trash2 size={13} />
</button>
<button
onClick={() => setMaximized((m) => !m)}
className={btn}
title={maximized ? "Restore (Esc)" : "Maximize"}
>
{maximized ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
</button>
</span>
</div>
<pre
ref={preRef}
onScroll={onScroll}
className={`p-3 text-xs font-mono text-gray-300 ${
maximized ? "flex-1 overflow-auto" : "overflow-x-auto max-h-96 overflow-y-auto"
}`}
>
{logs.length === 0 ? (
<span className="text-[var(--muted)]">No logs yet</span>
) : (
logs.map((line, i) => (
<div key={i} className="leading-5 hover:bg-white/5">
{line}
</div>
))
)}
</pre>
</div>
)
})