diff --git a/crates/lanspread-tauri-deno-ts/src/App.css b/crates/lanspread-tauri-deno-ts/src/App.css index 3efe4a4..99646b5 100644 --- a/crates/lanspread-tauri-deno-ts/src/App.css +++ b/crates/lanspread-tauri-deno-ts/src/App.css @@ -349,80 +349,3 @@ h1.align-center { left: 20px; z-index: 1001; } - -.unpack-log-window { - min-height: 100vh; - box-sizing: border-box; - padding: 18px; - background: #000313; - color: #D5DBFE; -} - -.unpack-log-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - margin-bottom: 14px; -} - -.unpack-log-header h1 { - margin: 0; - font-size: 22px; -} - -.unpack-log-textfield { - height: calc(100vh - 84px); - box-sizing: border-box; - overflow: auto; - padding: 14px; - border: 1px solid #444; - border-radius: 6px; - background: #050813; - font-family: Consolas, "Courier New", monospace; - font-size: 13px; - line-height: 1.35; - white-space: pre-wrap; -} - -.unpack-log-empty { - color: #8892b0; -} - -.unpack-log-entry { - padding-bottom: 16px; - margin-bottom: 16px; - border-bottom: 1px solid #26304f; -} - -.unpack-log-entry:last-child { - margin-bottom: 0; - border-bottom: 0; -} - -.unpack-log-meta { - margin-bottom: 6px; - font-weight: bold; -} - -.unpack-log-meta.success { - color: #8ee6a6; -} - -.unpack-log-meta.error, -.unpack-log-stream.stderr { - color: #ff8a8a; -} - -.unpack-log-path { - color: #aeb7df; -} - -.unpack-log-stream { - margin: 8px 0 0; - font: inherit; -} - -.unpack-log-stream.stdout { - color: #D5DBFE; -} diff --git a/crates/lanspread-tauri-deno-ts/src/App.tsx b/crates/lanspread-tauri-deno-ts/src/App.tsx index 36bb692..b7fa5f4 100644 --- a/crates/lanspread-tauri-deno-ts/src/App.tsx +++ b/crates/lanspread-tauri-deno-ts/src/App.tsx @@ -6,6 +6,7 @@ import { open } from '@tauri-apps/plugin-dialog'; import { load } from '@tauri-apps/plugin-store'; import "./App.css"; +import { UnpackLogsWindow, isUnpackLogsView } from './UnpackLogsWindow'; const FILE_STORAGE = 'launcher-settings.json'; const GAME_DIR_KEY = 'game-directory'; @@ -72,17 +73,6 @@ interface GamesListPayload { active_operations?: ActiveOperation[]; } -interface UnpackLogEntry { - archive: string; - destination: string; - status_code: number | null; - stdout: string; - stderr: string; - started_at_ms: number; - finished_at_ms: number; - success: boolean; -} - interface GameThumbnailProps { gameId: string; alt: string; @@ -163,72 +153,6 @@ const normalizeGamesListPayload = (payload: GamesListPayload | Game[]): GamesLis return payload; }; -const isUnpackLogsView = (): boolean => { - return new URLSearchParams(window.location.search).get('view') === 'unpack-logs'; -}; - -const formatLogTime = (timestampMs: number): string => { - if (timestampMs <= 0) { - return 'unknown time'; - } - return new Date(timestampMs).toLocaleString(); -}; - -const UnpackLogsWindow = () => { - const [logs, setLogs] = useState([]); - - const refreshLogs = useCallback(async () => { - const unpackLogs = await invoke('get_unpack_logs'); - setLogs(unpackLogs); - }, []); - - useEffect(() => { - let unlisten: (() => void) | undefined; - - const setup = async () => { - await refreshLogs(); - unlisten = await listen('unpack-logs-updated', () => { - void refreshLogs(); - }); - }; - - void setup(); - - return () => { - unlisten?.(); - }; - }, [refreshLogs]); - - return ( -
-
-

Unpack Logs

- -
-
- {logs.length === 0 ? ( -
No unpack logs recorded yet.
- ) : logs.map((entry, index) => ( -
-
- [{formatLogTime(entry.finished_at_ms)}] {entry.success ? 'OK' : 'FAILED'} - {' '}status={entry.status_code ?? 'none'} -
-
archive: {entry.archive}
-
dest: {entry.destination}
-
-                            {entry.stdout.trim() ? entry.stdout : '(stdout empty)'}
-                        
- {entry.stderr.trim() && ( -
{entry.stderr}
- )} -
- ))} -
-
- ); -}; - const mergeGameUpdate = ( game: Game, previous?: Game, diff --git a/crates/lanspread-tauri-deno-ts/src/UnpackLogsWindow.css b/crates/lanspread-tauri-deno-ts/src/UnpackLogsWindow.css new file mode 100644 index 0000000..f5bc9bc --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/UnpackLogsWindow.css @@ -0,0 +1,231 @@ +.unpack-log-window { + height: 100vh; + box-sizing: border-box; + padding: 18px; + background: #000313; + color: #D5DBFE; + display: flex; + flex-direction: column; + gap: 12px; +} + +.unpack-log-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-shrink: 0; +} + +.unpack-log-header h1 { + margin: 0; + font-size: 22px; +} + +.unpack-log-controls { + display: flex; + align-items: center; + gap: 12px; + flex: 1; + justify-content: flex-end; +} + +.unpack-log-toggle { + display: flex; + align-items: center; + gap: 6px; + color: #aeb7df; + font-size: 13px; + cursor: pointer; + user-select: none; +} + +.unpack-log-regex { + flex: 0 1 320px; + padding: 7px 12px; + font-family: Consolas, "Courier New", monospace; + font-size: 13px; + color: #D5DBFE; + background: #050813; + border: 1px solid #2a3252; + border-radius: 6px; + outline: none; +} + +.unpack-log-regex:focus { + border-color: #4866b9; +} + +.unpack-log-regex.invalid { + border-color: #ff6666; +} + +.unpack-log-regex-error { + flex-shrink: 0; + color: #ff8a8a; + font-size: 12px; + font-family: Consolas, "Courier New", monospace; +} + +.unpack-log-body { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: minmax(220px, 320px) 1fr; + gap: 12px; +} + +.unpack-log-list { + display: flex; + flex-direction: column; + gap: 4px; + overflow-y: auto; + padding: 8px; + border: 1px solid #2a3252; + border-radius: 6px; + background: #050813; +} + +.unpack-log-list-stats { + color: #8892b0; + font-size: 12px; + padding: 2px 4px 6px; + border-bottom: 1px solid #26304f; + margin-bottom: 4px; +} + +.unpack-log-list-item { + display: grid; + grid-template-columns: 44px 1fr auto auto; + gap: 8px; + align-items: center; + padding: 6px 8px; + text-align: left; + color: #D5DBFE; + background: transparent; + border: 1px solid transparent; + border-radius: 4px; + font-family: Consolas, "Courier New", monospace; + font-size: 12px; + cursor: pointer; +} + +.unpack-log-list-item:hover { + background: #0d1530; +} + +.unpack-log-list-item.active { + background: #14224d; + border-color: #4866b9; +} + +.unpack-log-list-badge { + font-weight: bold; + text-align: center; + padding: 2px 4px; + border-radius: 3px; + font-size: 11px; +} + +.unpack-log-list-item.success .unpack-log-list-badge { + color: #050813; + background: #8ee6a6; +} + +.unpack-log-list-item.error .unpack-log-list-badge { + color: #050813; + background: #ff8a8a; +} + +.unpack-log-list-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.unpack-log-list-time { + color: #8892b0; + font-size: 11px; +} + +.unpack-log-list-matches { + color: #aeb7df; + background: #14224d; + border-radius: 10px; + padding: 1px 8px; + font-size: 11px; + min-width: 16px; + text-align: center; +} + +.unpack-log-detail { + overflow: auto; + padding: 14px; + border: 1px solid #2a3252; + border-radius: 6px; + background: #050813; + font-family: Consolas, "Courier New", monospace; + font-size: 13px; + line-height: 1.35; + display: flex; + flex-direction: column; + gap: 10px; +} + +.unpack-log-detail-nav { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-shrink: 0; +} + +.unpack-log-detail-pos { + color: #8892b0; + font-size: 12px; +} + +.unpack-log-empty { + color: #8892b0; + padding: 8px 4px; +} + +.unpack-log-empty-stream { + color: #8892b0; + font-style: italic; +} + +.unpack-log-entry { + display: flex; + flex-direction: column; +} + +.unpack-log-meta { + margin-bottom: 6px; + font-weight: bold; +} + +.unpack-log-meta.success { + color: #8ee6a6; +} + +.unpack-log-meta.error, +.unpack-log-stream.stderr { + color: #ff8a8a; +} + +.unpack-log-path { + color: #aeb7df; + word-break: break-all; +} + +.unpack-log-stream { + margin: 8px 0 0; + font: inherit; + white-space: pre-wrap; + word-break: break-word; +} + +.unpack-log-stream.stdout { + color: #D5DBFE; +} diff --git a/crates/lanspread-tauri-deno-ts/src/UnpackLogsWindow.tsx b/crates/lanspread-tauri-deno-ts/src/UnpackLogsWindow.tsx new file mode 100644 index 0000000..16a4ce5 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src/UnpackLogsWindow.tsx @@ -0,0 +1,251 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; + +import './UnpackLogsWindow.css'; + +interface UnpackLogEntry { + archive: string; + destination: string; + status_code: number | null; + stdout: string; + stderr: string; + started_at_ms: number; + finished_at_ms: number; + success: boolean; +} + +interface FilteredEntry { + entry: UnpackLogEntry; + originalIndex: number; + stdoutLines: string[]; + stderrLines: string[]; + matchCount: number; +} + +export const isUnpackLogsView = (): boolean => + new URLSearchParams(window.location.search).get('view') === 'unpack-logs'; + +const formatLogTime = (timestampMs: number): string => { + if (timestampMs <= 0) { + return 'unknown time'; + } + return new Date(timestampMs).toLocaleString(); +}; + +const basename = (path: string): string => { + const segments = path.split(/[\\/]/); + return segments[segments.length - 1] || path; +}; + +const nonEmptyLines = (text: string): string[] => + text.split(/\r?\n/).filter(line => line.trim().length > 0); + +export const UnpackLogsWindow = () => { + const [logs, setLogs] = useState([]); + const [errorsOnly, setErrorsOnly] = useState(false); + const [regexInput, setRegexInput] = useState(''); + const [selectedOriginalIndex, setSelectedOriginalIndex] = useState(null); + + const refreshLogs = useCallback(async () => { + const unpackLogs = await invoke('get_unpack_logs'); + setLogs(unpackLogs); + }, []); + + useEffect(() => { + let unlisten: (() => void) | undefined; + + const setup = async () => { + await refreshLogs(); + unlisten = await listen('unpack-logs-updated', () => { + void refreshLogs(); + }); + }; + + void setup(); + + return () => { + unlisten?.(); + }; + }, [refreshLogs]); + + const { regex, regexError } = useMemo(() => { + if (!regexInput) { + return { regex: null as RegExp | null, regexError: null as string | null }; + } + try { + return { regex: new RegExp(regexInput, 'i'), regexError: null }; + } catch (e) { + return { regex: null, regexError: e instanceof Error ? e.message : String(e) }; + } + }, [regexInput]); + + const filteredLogs = useMemo(() => { + const out: FilteredEntry[] = []; + logs.forEach((entry, originalIndex) => { + if (errorsOnly && entry.success) return; + + const stdoutClean = nonEmptyLines(entry.stdout); + const stderrClean = nonEmptyLines(entry.stderr); + + const stdoutLines = regex ? stdoutClean.filter(line => regex.test(line)) : stdoutClean; + const stderrLines = regex ? stderrClean.filter(line => regex.test(line)) : stderrClean; + const matchCount = stdoutLines.length + stderrLines.length; + + // With an active regex, hide entries that contribute no matching lines. + if (regex && matchCount === 0) return; + + out.push({ entry, originalIndex, stdoutLines, stderrLines, matchCount }); + }); + return out; + }, [logs, errorsOnly, regex]); + + const selectedListIndex = useMemo(() => { + if (filteredLogs.length === 0) return -1; + if (selectedOriginalIndex === null) return 0; + const idx = filteredLogs.findIndex(item => item.originalIndex === selectedOriginalIndex); + return idx >= 0 ? idx : 0; + }, [filteredLogs, selectedOriginalIndex]); + + const current = selectedListIndex >= 0 ? filteredLogs[selectedListIndex] : null; + + const goTo = useCallback((listIndex: number) => { + if (listIndex < 0 || listIndex >= filteredLogs.length) return; + setSelectedOriginalIndex(filteredLogs[listIndex].originalIndex); + }, [filteredLogs]); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + const target = e.target as HTMLElement | null; + if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return; + if (e.key === 'ArrowUp' || e.key === 'k') { + e.preventDefault(); + goTo(selectedListIndex - 1); + } else if (e.key === 'ArrowDown' || e.key === 'j') { + e.preventDefault(); + goTo(selectedListIndex + 1); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [goTo, selectedListIndex]); + + const activeItemRef = useRef(null); + useEffect(() => { + activeItemRef.current?.scrollIntoView({ block: 'nearest' }); + }, [selectedListIndex]); + + return ( +
+
+

Unpack Logs

+
+ + setRegexInput(e.target.value)} + title={regexError ?? ''} + spellCheck={false} + /> + +
+
+ {regexError && ( +
regex error: {regexError}
+ )} +
+ +
+ {current ? ( + <> +
+ + + {selectedListIndex + 1} / {filteredLogs.length} + {regex && ` · ${current.matchCount} matching lines`} + + +
+
+
+ [{formatLogTime(current.entry.finished_at_ms)}] {current.entry.success ? 'OK' : 'FAILED'} + {' '}status={current.entry.status_code ?? 'none'} +
+
archive: {current.entry.archive}
+
dest: {current.entry.destination}
+ {current.stdoutLines.length > 0 ? ( +
{current.stdoutLines.join('\n')}
+ ) : ( +
+ {regex ? '(stdout: no matching lines)' : '(stdout empty)'} +
+ )} + {current.stderrLines.length > 0 && ( +
{current.stderrLines.join('\n')}
+ )} +
+ + ) : ( +
Nothing to show.
+ )} +
+
+
+ ); +};