Add a durable, acknowledged Local network sharing switch with fail-closed hydration, serialized mutation, and redacted ephemeral-identity diagnostics. Keep local Call-to-Play state available while gating every network action on the effective sharing generation. Render revisioned verification, invalid-source retry, and sticky source exhaustion states. Preserve opaque attempt IDs through progress delivery so out-of-order webview events cannot attach stale bytes to a successor transfer, and keep terminal exhaustion visible after the last source departs. Own listeners, native invokes, persistence, dialogs, and companion-window creation through webview close. Late creation is settled and cleaned before the parent realm is destroyed. Test Plan: - `just frontend-test` -- passed (91/91) - `just build` -- passed with TypeScript, Vite, and release Tauri compilation - `just test` -- passed on the completed stack (708 workspace tests) - `just clippy` -- passed on the completed stack - `git diff --cached --check` -- passed
314 lines
12 KiB
TypeScript
314 lines
12 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 { SegmentedRadio } from './components/SegmentedRadio';
|
|
import {
|
|
capLogRows,
|
|
consumeLoadedHistoryRow,
|
|
dedupeBufferedRows,
|
|
formatCount,
|
|
LEVEL_FILTER_MIN,
|
|
LEVEL_FILTER_OPTIONS,
|
|
LEVEL_ORDER,
|
|
lineCountsFromRows,
|
|
type LevelFilter,
|
|
type MainLogHistoryPayload,
|
|
type MainLogLinePayload,
|
|
type MainLogRow,
|
|
rowFromPayload,
|
|
rowsFromHistory,
|
|
} from './lib/mainLogs';
|
|
import { AsyncOwner, windowAsyncScope } from './lib/asyncOwnership';
|
|
|
|
import './MainLogsWindow.css';
|
|
|
|
export const isMainLogsView = (): boolean =>
|
|
new URLSearchParams(window.location.search).get('view') === 'main-logs';
|
|
|
|
export const MainLogsWindow = () => {
|
|
const [logs, setLogs] = useState<MainLogRow[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
const [regexInput, setRegexInput] = useState('');
|
|
const [levelFilter, setLevelFilter] = useState<LevelFilter>('all');
|
|
const [autoScroll, setAutoScroll] = useState(true);
|
|
const [paused, setPaused] = useState(false);
|
|
const [pausedBufferCount, setPausedBufferCount] = useState(0);
|
|
const [copyStatus, setCopyStatus] = useState<string | null>(null);
|
|
|
|
const viewportRef = useRef<HTMLDivElement | null>(null);
|
|
const historyLoadedRef = useRef(false);
|
|
const initialBufferRef = useRef<MainLogRow[]>([]);
|
|
const pausedBufferRef = useRef<MainLogRow[]>([]);
|
|
const pausedRef = useRef(false);
|
|
const lastHistorySequenceRef = useRef(0);
|
|
const historyLineCountsRef = useRef<Map<string, number>>(new Map());
|
|
const viewOwnerRef = useRef<AsyncOwner | null>(null);
|
|
const copyStatusTimerRef = useRef<number | undefined>(undefined);
|
|
|
|
const appendVisibleRows = useCallback((rows: MainLogRow[]) => {
|
|
setLogs(current => capLogRows([...current, ...rows]));
|
|
}, []);
|
|
|
|
const bufferPausedRows = useCallback((rows: MainLogRow[]) => {
|
|
pausedBufferRef.current = capLogRows([...pausedBufferRef.current, ...rows]);
|
|
setPausedBufferCount(pausedBufferRef.current.length);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const owner = new AsyncOwner(error => {
|
|
console.error('Failed to unregister main log listener:', error);
|
|
});
|
|
viewOwnerRef.current = owner;
|
|
owner.own(() => {
|
|
if (copyStatusTimerRef.current !== undefined) {
|
|
window.clearTimeout(copyStatusTimerRef.current);
|
|
copyStatusTimerRef.current = undefined;
|
|
}
|
|
});
|
|
|
|
const handleIncomingRow = owner.guard((row: MainLogRow) => {
|
|
if (!historyLoadedRef.current) {
|
|
initialBufferRef.current = capLogRows([...initialBufferRef.current, row]);
|
|
return;
|
|
}
|
|
if (
|
|
consumeLoadedHistoryRow(
|
|
historyLineCountsRef.current,
|
|
row,
|
|
lastHistorySequenceRef.current,
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
if (pausedRef.current) {
|
|
bufferPausedRows([row]);
|
|
return;
|
|
}
|
|
appendVisibleRows([row]);
|
|
});
|
|
|
|
const setup = async () => {
|
|
try {
|
|
const registered = await owner.register(() =>
|
|
listen<MainLogLinePayload>('main-log-line', event => {
|
|
handleIncomingRow(rowFromPayload(event.payload));
|
|
})
|
|
);
|
|
if (!registered) return;
|
|
} catch (err) {
|
|
if (owner.isActive()) {
|
|
historyLoadedRef.current = true;
|
|
setLoadError(err instanceof Error ? err.message : String(err));
|
|
setLoading(false);
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await owner.applyIfActive(
|
|
() => invoke<MainLogHistoryPayload>('get_main_logs'),
|
|
history => {
|
|
lastHistorySequenceRef.current = history.lastSequence;
|
|
const historyRows = rowsFromHistory(history.contents);
|
|
const historyLineCounts = lineCountsFromRows(historyRows);
|
|
const liveRows = dedupeBufferedRows(
|
|
historyLineCounts,
|
|
initialBufferRef.current,
|
|
lastHistorySequenceRef.current,
|
|
);
|
|
initialBufferRef.current = [];
|
|
historyLineCountsRef.current = historyLineCounts;
|
|
historyLoadedRef.current = true;
|
|
|
|
if (pausedRef.current) {
|
|
setLogs(capLogRows(historyRows));
|
|
bufferPausedRows(liveRows);
|
|
} else {
|
|
setLogs(capLogRows([...historyRows, ...liveRows]));
|
|
}
|
|
setLoadError(null);
|
|
setLoading(false);
|
|
},
|
|
);
|
|
} catch (err) {
|
|
if (owner.isActive()) {
|
|
historyLoadedRef.current = true;
|
|
setLoadError(err instanceof Error ? err.message : String(err));
|
|
setLoading(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
void setup();
|
|
|
|
return () => {
|
|
windowAsyncScope.adopt(owner.dispose());
|
|
if (viewOwnerRef.current === owner) viewOwnerRef.current = null;
|
|
historyLoadedRef.current = false;
|
|
initialBufferRef.current = [];
|
|
lastHistorySequenceRef.current = 0;
|
|
historyLineCountsRef.current = new Map();
|
|
};
|
|
}, [appendVisibleRows, bufferPausedRows]);
|
|
|
|
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 filteredRows = useMemo(() => {
|
|
const minLevel = LEVEL_FILTER_MIN[levelFilter];
|
|
return logs.filter(row => {
|
|
if (LEVEL_ORDER[row.level] < minLevel) return false;
|
|
return regex ? regex.test(row.line) : true;
|
|
});
|
|
}, [levelFilter, logs, regex]);
|
|
|
|
const lastVisibleRow = filteredRows.length > 0 ? filteredRows[filteredRows.length - 1] : null;
|
|
|
|
useEffect(() => {
|
|
if (!autoScroll) return;
|
|
const viewport = viewportRef.current;
|
|
if (!viewport) return;
|
|
|
|
const frame = requestAnimationFrame(() => {
|
|
viewport.scrollTop = viewport.scrollHeight;
|
|
});
|
|
return () => cancelAnimationFrame(frame);
|
|
}, [autoScroll, filteredRows.length, lastVisibleRow?.id]);
|
|
|
|
const flushPausedRows = useCallback(() => {
|
|
const buffered = pausedBufferRef.current;
|
|
if (buffered.length === 0) return;
|
|
pausedBufferRef.current = [];
|
|
setPausedBufferCount(0);
|
|
appendVisibleRows(buffered);
|
|
}, [appendVisibleRows]);
|
|
|
|
const togglePaused = useCallback(() => {
|
|
if (paused) {
|
|
pausedRef.current = false;
|
|
setPaused(false);
|
|
flushPausedRows();
|
|
return;
|
|
}
|
|
|
|
pausedRef.current = true;
|
|
setPaused(true);
|
|
}, [flushPausedRows, paused]);
|
|
|
|
const clearLogs = useCallback(() => {
|
|
setLogs([]);
|
|
initialBufferRef.current = [];
|
|
pausedBufferRef.current = [];
|
|
setPausedBufferCount(0);
|
|
}, []);
|
|
|
|
const copyFilteredLogs = useCallback(async () => {
|
|
const owner = viewOwnerRef.current;
|
|
if (!owner) return;
|
|
try {
|
|
if (!await owner.applyIfActive(
|
|
() => navigator.clipboard.writeText(filteredRows.map(row => row.line).join('\n')),
|
|
() => setCopyStatus('Copied'),
|
|
)) return;
|
|
} catch {
|
|
if (!owner.isActive()) return;
|
|
setCopyStatus('Copy failed');
|
|
}
|
|
if (copyStatusTimerRef.current !== undefined) {
|
|
window.clearTimeout(copyStatusTimerRef.current);
|
|
}
|
|
copyStatusTimerRef.current = window.setTimeout(owner.guard(() => {
|
|
copyStatusTimerRef.current = undefined;
|
|
setCopyStatus(null);
|
|
}), 1600);
|
|
}, [filteredRows]);
|
|
|
|
return (
|
|
<main className="main-log-window">
|
|
<div className="main-log-header">
|
|
<h1>Application Logs</h1>
|
|
<div className="main-log-controls">
|
|
<label className="main-log-toggle">
|
|
<input
|
|
type="checkbox"
|
|
checked={autoScroll}
|
|
onChange={(e) => setAutoScroll(e.target.checked)}
|
|
/>
|
|
Auto-scroll
|
|
</label>
|
|
<button className="settings-button" onClick={togglePaused}>
|
|
{paused ? 'Resume' : 'Pause'}
|
|
</button>
|
|
<button className="settings-button" onClick={clearLogs} disabled={logs.length === 0}>
|
|
Clear
|
|
</button>
|
|
<button
|
|
className="settings-button"
|
|
onClick={() => windowAsyncScope.adopt(copyFilteredLogs())}
|
|
disabled={filteredRows.length === 0}
|
|
>
|
|
Copy
|
|
</button>
|
|
{copyStatus && <span className="main-log-copy-status">{copyStatus}</span>}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="main-log-filter-row">
|
|
<div className="main-log-level-filter">
|
|
<SegmentedRadio
|
|
value={levelFilter}
|
|
options={LEVEL_FILTER_OPTIONS}
|
|
onChange={setLevelFilter}
|
|
/>
|
|
</div>
|
|
<input
|
|
className={`unpack-log-regex main-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}
|
|
/>
|
|
</div>
|
|
|
|
{regexError && (
|
|
<div className="unpack-log-regex-error">regex error: {regexError}</div>
|
|
)}
|
|
{loadError && (
|
|
<div className="main-log-load-error">load error: {loadError}</div>
|
|
)}
|
|
|
|
<div className="main-log-stats">
|
|
{loading ? 'loading' : `showing ${formatCount(filteredRows.length, 'line')} of ${formatCount(logs.length, 'line')}`}
|
|
{paused && pausedBufferCount > 0 && ` - ${formatCount(pausedBufferCount, 'paused line')}`}
|
|
</div>
|
|
|
|
<section ref={viewportRef} className="main-log-viewport" aria-live={paused ? 'off' : 'polite'}>
|
|
{filteredRows.length === 0 ? (
|
|
<div className="main-log-empty">
|
|
{logs.length === 0 ? 'No application logs recorded yet.' : 'No log lines match the current filters.'}
|
|
</div>
|
|
) : filteredRows.map(row => (
|
|
<div
|
|
key={row.id}
|
|
className={`main-log-line level-${row.level.toLowerCase()}`}
|
|
>
|
|
{row.line}
|
|
</div>
|
|
))}
|
|
</section>
|
|
</main>
|
|
);
|
|
};
|