diff --git a/src/pages/standalone/game-log.tsx b/src/pages/standalone/game-log.tsx index f4d44f17a..213f4299b 100644 --- a/src/pages/standalone/game-log.tsx +++ b/src/pages/standalone/game-log.tsx @@ -11,15 +11,25 @@ import { import { appLogDir, join } from "@tauri-apps/api/path"; import { getCurrentWebview } from "@tauri-apps/api/webview"; import { revealItemInDir } from "@tauri-apps/plugin-opener"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { LuChevronsDown, LuFileInput, LuTrash } from "react-icons/lu"; +import { + AutoSizer, + CellMeasurer, + CellMeasurerCache, + List, + ListRowRenderer, +} from "react-virtualized"; +import "react-virtualized/styles.css"; import Empty from "@/components/common/empty"; import { useLauncherConfig } from "@/contexts/config"; import { LaunchService } from "@/services/launch"; import styles from "@/styles/game-log.module.css"; import { parseIdFromWindowLabel } from "@/utils/window"; +type LogLevel = "FATAL" | "ERROR" | "WARN" | "INFO" | "DEBUG"; + const GameLogPage: React.FC = () => { const { t } = useTranslation(); const { config } = useLauncherConfig(); @@ -36,8 +46,27 @@ const GameLogPage: React.FC = () => { }); const [isScrolledToBottom, setIsScrolledToBottom] = useState(true); - const logContainerRef = useRef(null); const launchingIdRef = useRef(null); + const listRef = useRef(null); + const userScrolledRef = useRef(false); + + const cacheRef = useRef( + new CellMeasurerCache({ + fixedWidth: true, + defaultHeight: 20, + }) + ); + + const logLevelMap: Record< + LogLevel, + { colorScheme: string; textColor: string } + > = { + FATAL: { colorScheme: "red", textColor: "red.500" }, + ERROR: { colorScheme: "orange", textColor: "orange.500" }, + WARN: { colorScheme: "yellow", textColor: "yellow.500" }, + INFO: { colorScheme: "gray", textColor: "gray.600" }, + DEBUG: { colorScheme: "blue", textColor: "blue.600" }, + }; const clearLogs = () => setLogs([]); @@ -65,100 +94,113 @@ const GameLogPage: React.FC = () => { return () => unlisten(); }, []); + // scroll to bottom on new log if unclicked + useEffect(() => { + if (userScrolledRef.current) return; + + requestAnimationFrame(() => { + listRef.current?.scrollToRow(logs.length - 1); + }); + }, [logs.length]); + const revealRawLogFile = async () => { - try { - const launchingId = launchingIdRef.current; - if (launchingId == null) return; + if (!launchingIdRef.current) return; - const baseDir = await appLogDir(); - const logFilePath = await join( - baseDir, - "game", - `game_log_${launchingId}.log` - ); + const baseDir = await appLogDir(); + const logFilePath = await join( + baseDir, + "game", + `game_log_${launchingIdRef.current}.log` + ); - await revealItemInDir(logFilePath); - } catch (err) { - logger.error("Failed to open raw log file:", err); - } + await revealItemInDir(logFilePath); }; - let lastLevel: string = "INFO"; + const lastLevelRef = useRef("INFO"); - const getLogLevel = (log: string): string => { + const getLogLevel = useCallback((log: string): LogLevel => { const match = log.match( /\[\d{2}:\d{2}:\d{2}]\s+\[.*?\/(INFO|WARN|ERROR|DEBUG|FATAL)]/i ); if (match) { - lastLevel = match[1].toUpperCase(); - return lastLevel; + const level = match[1].toUpperCase() as LogLevel; + lastLevelRef.current = level; + return level; } if (/^\s+at /.test(log) || /^\s+Caused by:/.test(log) || /^\s+/.test(log)) { - return lastLevel; + return lastLevelRef.current; } if (/exception|error|invalid|failed|错误/i.test(log)) { - lastLevel = "ERROR"; + lastLevelRef.current = "ERROR"; return "ERROR"; } + return lastLevelRef.current; + }, []); - lastLevel = lastLevel || "INFO"; - return "INFO"; - }; - - const filteredLogs = logs.filter((log) => { - const level = getLogLevel(log); - return ( - filterStates[level] && - log.toLowerCase().includes(searchTerm.toLowerCase()) - ); - }); - - const logLevelMap: { - [key: string]: { colorScheme: string; color: string }; - } = { - FATAL: { colorScheme: "red", color: "red.500" }, - ERROR: { colorScheme: "orange", color: "orange.500" }, - WARN: { colorScheme: "yellow", color: "yellow.500" }, - INFO: { colorScheme: "gray", color: "gray.600" }, - DEBUG: { colorScheme: "gray", color: "blue.600" }, - }; + const logCounts = useMemo>(() => { + const counts: Record = { + FATAL: 0, + ERROR: 0, + WARN: 0, + INFO: 0, + DEBUG: 0, + }; - const logCounts = logs.reduce<{ [key: string]: number }>((acc, log) => { - const level = getLogLevel(log); - acc[level] = (acc[level] || 0) + 1; - return acc; - }, {}); + for (const log of logs) { + const level = getLogLevel(log); + counts[level]++; + } - // NOTE: smooth scroll may have delay, not always to bottom. - // const scrollToBottom = () => { - // if (logContainerRef.current) { - // logContainerRef.current.scrollTo({ - // top: logContainerRef.current.scrollHeight, - // behavior: "smooth", - // }); - // } - // }; + return counts; + }, [logs, getLogLevel]); - const scrollToBottom = () => { - if (logContainerRef.current) { - logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight; - } - }; + const filteredLogs = useMemo(() => { + return logs.filter((log) => { + const level = getLogLevel(log); + return ( + filterStates[level] && + log.toLowerCase().includes(searchTerm.toLowerCase()) + ); + }); + }, [logs, filterStates, searchTerm, getLogLevel]); - const handleScroll = () => { - if (!logContainerRef.current) return; + const rowRenderer: ListRowRenderer = ({ key, index, style, parent }) => { + const log = filteredLogs[index]; + const level = getLogLevel(log); - const { scrollTop, scrollHeight, clientHeight } = logContainerRef.current; - const atBottom = scrollHeight - scrollTop - clientHeight < 1; - setIsScrolledToBottom(atBottom); + return ( + +
+ + {log} + +
+
+ ); }; - // Auto scroll to bottom if user not interacted + // Reset list cache and recalculate row heights on filteredLogs update useEffect(() => { - if (isScrolledToBottom) scrollToBottom(); - }, [filteredLogs, isScrolledToBottom]); + cacheRef.current.clearAll(); + listRef.current?.recomputeRowHeights(); + }, [filteredLogs]); + + const levels = Object.keys(logLevelMap) as LogLevel[]; return ( @@ -174,7 +216,8 @@ const GameLogPage: React.FC = () => { focusBorderColor={`${primaryColor}.500`} /> - {Object.keys(logLevelMap).map((level) => ( + + {levels.map((level) => (