import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Box, Button, Menu, MenuItem, Typography } from "@mui/material"; import ListIcon from "@mui/icons-material/List"; import { skipToken, useQuery } from "@tanstack/react-query"; import hljs from "highlight.js/lib/core"; import plaintext from "highlight.js/lib/languages/plaintext"; import "highlight.js/styles/github.css"; import { Client } from "api/client/AhrimanClient"; import { QueryKeys } from "api/QueryKeys"; import { formatTimestamp } from "components/common/formatTimestamp"; import CopyButton from "components/common/CopyButton"; import type { LogRecord } from "api/types/LogRecord"; import type { RepositoryId } from "api/types/RepositoryId"; hljs.registerLanguage("plaintext", plaintext); interface LogVersion { version: string; processId: string; created: number; logs: string; } interface BuildLogsTabProps { packageBase: string; repo: RepositoryId; refetchInterval: number | false; } function convertLogs(records: LogRecord[], filter?: (r: LogRecord) => boolean): string { return records .filter(filter || Boolean) .map((r) => `[${new Date(r.created * 1000).toISOString()}] ${r.message}`) .join("\n"); } export default function BuildLogsTab({ packageBase, repo, refetchInterval }: BuildLogsTabProps): React.JSX.Element { const [activeIndex, setActiveIndex] = useState(0); const [anchorEl, setAnchorEl] = useState(null); const codeRef = useRef(null); const preRef = useRef(null); const initialScrollDone = useRef(false); const wasAtBottom = useRef(true); const handleScroll = useCallback(() => { if (preRef.current) { const el = preRef.current; wasAtBottom.current = el.scrollTop + el.clientHeight >= el.scrollHeight - 50; } }, []); const { data: allLogs } = useQuery({ queryKey: QueryKeys.logs(packageBase, repo), queryFn: () => Client.fetchLogs(packageBase, repo), enabled: !!packageBase, }); // Build version selectors from all logs const versions = useMemo(() => { if (!allLogs || allLogs.length === 0) { return []; } const grouped: Record = {}; for (const record of allLogs) { const key = `${record.version}-${record.process_id}`; if (!grouped[key]) { grouped[key] = { ...record, minCreated: record.created }; } else { grouped[key].minCreated = Math.min(grouped[key].minCreated, record.created); } } return Object.values(grouped) .sort((a, b) => b.minCreated - a.minCreated) .map((v) => ({ version: v.version, processId: v.process_id, created: v.minCreated, logs: convertLogs( allLogs, (r) => r.version === v.version && r.process_id === v.process_id, ), })); }, [allLogs]); // Reset active index when logs data changes (React "adjusting state from props" pattern) const [prevAllLogs, setPrevAllLogs] = useState(allLogs); if (prevAllLogs !== allLogs) { setPrevAllLogs(allLogs); setActiveIndex(0); } // Reset scroll tracking when active version changes const activeVersionKey = versions[activeIndex] ? `${versions[activeIndex].version}-${versions[activeIndex].processId}` : null; useEffect(() => { initialScrollDone.current = false; }, [activeVersionKey]); // Refresh active version logs when using auto-refresh const activeVersion = versions[activeIndex]; const { data: versionLogs } = useQuery({ queryKey: activeVersion ? QueryKeys.logsVersion(packageBase, repo, activeVersion.version, activeVersion.processId) : ["logs-none"], queryFn: activeVersion ? () => Client.fetchLogs(packageBase, repo, activeVersion.version, activeVersion.processId) : skipToken, enabled: !!activeVersion && !!refetchInterval, refetchInterval, }); // Derive displayed logs: prefer fresh polled data when available const displayedLogs = useMemo(() => { if (versionLogs && versionLogs.length > 0) { return convertLogs(versionLogs); } return activeVersion?.logs ?? ""; }, [versionLogs, activeVersion]); // Highlight code useEffect(() => { if (codeRef.current && displayedLogs) { codeRef.current.textContent = displayedLogs; delete codeRef.current.dataset.highlighted; hljs.highlightElement(codeRef.current); } }, [displayedLogs]); // Auto-scroll: always scroll to bottom on initial load, then only if already near bottom // and the user has no active text selection (to avoid disrupting copy workflows). // wasAtBottom is tracked via onScroll so it reflects position *before* new content arrives. useEffect(() => { if (preRef.current && displayedLogs) { const el = preRef.current; if (!initialScrollDone.current) { el.scrollTop = el.scrollHeight; initialScrollDone.current = true; } else { const hasSelection = !document.getSelection()?.isCollapsed; if (wasAtBottom.current && !hasSelection) { el.scrollTop = el.scrollHeight; } } } }, [displayedLogs]); return (