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>
This commit is contained in:
@@ -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<UnpackLogEntry[]>([]);
|
||||
|
||||
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]);
|
||||
|
||||
return (
|
||||
<main className="unpack-log-window">
|
||||
<div className="unpack-log-header">
|
||||
<h1>Unpack Logs</h1>
|
||||
<button className="settings-button" onClick={() => void refreshLogs()}>Refresh</button>
|
||||
</div>
|
||||
<div className="unpack-log-textfield" role="textbox" aria-readonly="true" tabIndex={0}>
|
||||
{logs.length === 0 ? (
|
||||
<div className="unpack-log-empty">No unpack logs recorded yet.</div>
|
||||
) : logs.map((entry, index) => (
|
||||
<section className="unpack-log-entry" key={`${entry.finished_at_ms}-${index}`}>
|
||||
<div className={`unpack-log-meta ${entry.success ? 'success' : 'error'}`}>
|
||||
[{formatLogTime(entry.finished_at_ms)}] {entry.success ? 'OK' : 'FAILED'}
|
||||
{' '}status={entry.status_code ?? 'none'}
|
||||
</div>
|
||||
<div className="unpack-log-path">archive: {entry.archive}</div>
|
||||
<div className="unpack-log-path">dest: {entry.destination}</div>
|
||||
<pre className="unpack-log-stream stdout">
|
||||
{entry.stdout.trim() ? entry.stdout : '(stdout empty)'}
|
||||
</pre>
|
||||
{entry.stderr.trim() && (
|
||||
<pre className="unpack-log-stream stderr">{entry.stderr}</pre>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
const mergeGameUpdate = (
|
||||
game: Game,
|
||||
previous?: Game,
|
||||
|
||||
Reference in New Issue
Block a user