a375471b94
Unpack logs lived only in memory, so closing the app dropped history. Unrar progress also flooded stdout with carriage-return redraws, which made the log viewer noisy and hard to search. Persist the last twenty entries to unpack-logs.json under the app data directory, load them on startup, and rewrite stdout/stderr through a small terminal-sequence cleaner (CR/LF, backspace, control chars) before storage and display. Sort the unpack-logs window newest-first by finish or start time. Test plan: - cargo test -p lanspread-tauri-deno-ts -- terminal_log unpack_log - Run an unpack, restart the app, open unpack logs: prior entries remain - Confirm progress lines collapse to final text instead of spam Co-authored-by: Cursor <cursoragent@cursor.com>
259 lines
11 KiB
TypeScript
259 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 logSortTime = (entry: UnpackLogEntry): number =>
|
|
entry.finished_at_ms > 0 ? entry.finished_at_ms : entry.started_at_ms;
|
|
|
|
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 });
|
|
});
|
|
out.sort((a, b) => {
|
|
const timestampDelta = logSortTime(b.entry) - logSortTime(a.entry);
|
|
return timestampDelta !== 0 ? timestampDelta : b.originalIndex - a.originalIndex;
|
|
});
|
|
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>
|
|
);
|
|
};
|