Files
lanspread/crates/lanspread-tauri-deno-ts/src/UnpackLogsWindow.tsx
T
ddidderr ff35f0d95f feat(tauri): make unpack logs viewer usable for debugging
The original unpack logs window was a flat, monolithic scroll of every
unrar invocation glued together as one continuous textfield. That is
fine for a sanity check but hostile to actually finding a failed
extraction in a session with 30+ games: empty lines from unrar bloated
the view, there was no way to focus on a single game, no filtering, and
no way to narrow in on the entries that actually failed.

This rewrites the viewer to be a proper debugging surface while keeping
the backend untouched -- it still consumes the existing
`get_unpack_logs` command and `unpack-logs-updated` event.

User-visible changes:

* Empty / whitespace-only lines are stripped from stdout and stderr
  before rendering, so unrar's padding no longer drowns out real output.
* Two-pane layout: a sidebar lists every captured invocation (badge,
  archive basename, finish time); the right pane shows the selected
  entry's metadata, stdout and stderr.
* "Errors only" checkbox filters the sidebar to entries whose `success`
  flag is false (sidecar exit != 0 or one of the pre-spawn failure
  paths). This is the primary affordance for "find the unpack that
  broke".
* Regex input filters lines (not entries) -- both per-log when viewing
  one, and across the list: entries that contribute zero matching lines
  are hidden, and the remaining ones display a per-entry match counter
  next to the badge. Regex is case-insensitive; a bad pattern reddens
  the input and renders the parser error inline rather than silently
  dropping all matches.
* Prev / Next buttons plus arrow keys (and j/k) step through the
  filtered list one entry at a time, with the active row auto-scrolled
  into view. Selection is tracked by the entry's index in the full log
  ring so it survives filter toggles and live appends.

Code organization:

The component, its types, helpers (`basename`, `nonEmptyLines`,
`formatLogTime`, `isUnpackLogsView`) and its CSS are moved out of
`App.tsx` / `App.css` into a dedicated `UnpackLogsWindow.tsx` +
`UnpackLogsWindow.css` pair. The viewer has no shared state with the
main window and lives behind its own `?view=unpack-logs` route, so
keeping ~200 lines of debug-UI plumbing inside `App.tsx` was just
noise. `App.tsx` now imports `UnpackLogsWindow` and `isUnpackLogsView`
and otherwise looks the same as before.

Intentionally out of scope:

* No backend changes. The Rust side already records everything needed;
  this is purely a presentation improvement.
* No "view all logs concatenated" mode. The flat view was what we just
  replaced -- if it is ever wanted back, it can be added as a third
  pane mode.
* Regex is applied to displayed lines only, not to archive paths or
  meta. Filtering by archive name is easy enough via the basename in
  the sidebar; adding a second filter for it now would be premature.
* Logs are still process-local and capped at `MAX_UNPACK_LOGS` (100)
  in the Rust state -- unchanged from b35755f.

Test plan:

* `tsc --noEmit` and `vite build` are clean.
* Manual: trigger several successful and failed unpacks (rename one
  archive between handshake and extraction to force a canonicalize
  failure), open Unpack Logs, and verify:
    - empty lines are gone from stdout/stderr,
    - sidebar lists every entry with the right OK/FAIL badge,
    - "Errors only" hides the OK rows,
    - typing a regex narrows lines in the open entry, hides entries
      with no matches, and shows the per-entry match counts,
    - an invalid regex (e.g. `[`) reddens the field and shows the
      parser error rather than crashing,
    - arrow keys / j / k step through the filtered list and the
      active row scrolls into view,
    - new entries arriving via `unpack-logs-updated` while the window
      is open keep the current selection rather than jumping.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 19:54:50 +02:00

252 lines
11 KiB
TypeScript

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<UnpackLogEntry[]>([]);
const [errorsOnly, setErrorsOnly] = useState(false);
const [regexInput, setRegexInput] = useState('');
const [selectedOriginalIndex, setSelectedOriginalIndex] = useState<number | null>(null);
const refreshLogs = useCallback(async () => {
const unpackLogs = await invoke<UnpackLogEntry[]>('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<FilteredEntry[]>(() => {
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<HTMLButtonElement | null>(null);
useEffect(() => {
activeItemRef.current?.scrollIntoView({ block: 'nearest' });
}, [selectedListIndex]);
return (
<main className="unpack-log-window">
<div className="unpack-log-header">
<h1>Unpack Logs</h1>
<div className="unpack-log-controls">
<label className="unpack-log-toggle">
<input
type="checkbox"
checked={errorsOnly}
onChange={(e) => setErrorsOnly(e.target.checked)}
/>
Errors only
</label>
<input
className={`unpack-log-regex ${regexError ? 'invalid' : ''}`}
type="text"
placeholder="Filter lines by regex (case-insensitive)..."
value={regexInput}
onChange={(e) => setRegexInput(e.target.value)}
title={regexError ?? ''}
spellCheck={false}
/>
<button className="settings-button" onClick={() => void refreshLogs()}>Refresh</button>
</div>
</div>
{regexError && (
<div className="unpack-log-regex-error">regex error: {regexError}</div>
)}
<div className="unpack-log-body">
<aside className="unpack-log-list">
<div className="unpack-log-list-stats">
showing {filteredLogs.length} of {logs.length}
</div>
{filteredLogs.length === 0 ? (
<div className="unpack-log-empty">
{logs.length === 0 ? 'No unpack logs recorded yet.' : 'No logs match the current filters.'}
</div>
) : filteredLogs.map((item, listIndex) => {
const isActive = listIndex === selectedListIndex;
return (
<button
key={`${item.originalIndex}-${item.entry.finished_at_ms}`}
ref={isActive ? activeItemRef : undefined}
className={`unpack-log-list-item ${isActive ? 'active' : ''} ${item.entry.success ? 'success' : 'error'}`}
onClick={() => goTo(listIndex)}
>
<span className="unpack-log-list-badge">
{item.entry.success ? 'OK' : 'FAIL'}
</span>
<span className="unpack-log-list-name" title={item.entry.archive}>
{basename(item.entry.archive)}
</span>
<span className="unpack-log-list-time">
{item.entry.finished_at_ms > 0
? new Date(item.entry.finished_at_ms).toLocaleTimeString()
: '--:--:--'}
</span>
{regex && (
<span className="unpack-log-list-matches">{item.matchCount}</span>
)}
</button>
);
})}
</aside>
<section className="unpack-log-detail">
{current ? (
<>
<div className="unpack-log-detail-nav">
<button
className="settings-button"
onClick={() => goTo(selectedListIndex - 1)}
disabled={selectedListIndex <= 0}
>
Prev
</button>
<span className="unpack-log-detail-pos">
{selectedListIndex + 1} / {filteredLogs.length}
{regex && ` · ${current.matchCount} matching lines`}
</span>
<button
className="settings-button"
onClick={() => goTo(selectedListIndex + 1)}
disabled={selectedListIndex >= filteredLogs.length - 1}
>
Next
</button>
</div>
<article className="unpack-log-entry">
<div className={`unpack-log-meta ${current.entry.success ? 'success' : 'error'}`}>
[{formatLogTime(current.entry.finished_at_ms)}] {current.entry.success ? 'OK' : 'FAILED'}
{' '}status={current.entry.status_code ?? 'none'}
</div>
<div className="unpack-log-path">archive: {current.entry.archive}</div>
<div className="unpack-log-path">dest: {current.entry.destination}</div>
{current.stdoutLines.length > 0 ? (
<pre className="unpack-log-stream stdout">{current.stdoutLines.join('\n')}</pre>
) : (
<div className="unpack-log-empty-stream">
{regex ? '(stdout: no matching lines)' : '(stdout empty)'}
</div>
)}
{current.stderrLines.length > 0 && (
<pre className="unpack-log-stream stderr">{current.stderrLines.join('\n')}</pre>
)}
</article>
</>
) : (
<div className="unpack-log-empty">Nothing to show.</div>
)}
</section>
</div>
</main>
);
};