diff --git a/frontend/src/components/TracePane.tsx b/frontend/src/components/TracePane.tsx index 1391ef7..06ef8b5 100644 --- a/frontend/src/components/TracePane.tsx +++ b/frontend/src/components/TracePane.tsx @@ -28,6 +28,48 @@ import { cn } from '@/lib/utils'; type TraceSortMode = 'alpha' | 'recent' | 'distance'; type CustomHopBytes = 1 | 2 | 4; +const RECENT_TRACES_KEY = 'remoteterm-recent-traces'; +const MAX_RECENT_TRACES = 5; + +interface SavedTraceHop { + kind: 'repeater' | 'custom'; + publicKey?: string; + hopHex?: string; + hopBytes?: CustomHopBytes; + displayName: string; +} + +interface SavedTrace { + hops: SavedTraceHop[]; + ranAt: number; +} + +function loadRecentTraces(): SavedTrace[] { + try { + const raw = localStorage.getItem(RECENT_TRACES_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.slice(0, MAX_RECENT_TRACES) : []; + } catch { + return []; + } +} + +function saveRecentTrace(trace: SavedTrace): void { + try { + const existing = loadRecentTraces(); + // Dedupe by hop signature + const sig = trace.hops.map((h) => h.publicKey ?? h.hopHex ?? '').join(','); + const deduped = existing.filter( + (t) => t.hops.map((h) => h.publicKey ?? h.hopHex ?? '').join(',') !== sig + ); + const updated = [trace, ...deduped].slice(0, MAX_RECENT_TRACES); + localStorage.setItem(RECENT_TRACES_KEY, JSON.stringify(updated)); + } catch { + // localStorage may be disabled + } +} + type TraceDraftHop = | { id: string; kind: 'repeater'; publicKey: string } | { id: string; kind: 'custom'; hopHex: string; hopBytes: CustomHopBytes }; @@ -154,6 +196,7 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps) const [customHopBytesDraft, setCustomHopBytesDraft] = useState(1); const [customHopHexDraft, setCustomHopHexDraft] = useState(''); const [customHopError, setCustomHopError] = useState(null); + const [recentTraces, setRecentTraces] = useState(loadRecentTraces); const activeRunTokenRef = useRef(0); const repeaters = useMemo(() => { @@ -272,6 +315,56 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps) clearPendingResult(); }; + const handleLoadRecentTrace = async (trace: SavedTrace) => { + const hops: TraceDraftHop[] = trace.hops.map((h, i) => { + if (h.kind === 'repeater' && h.publicKey) { + return { + id: nextDraftHopId('repeater', i), + kind: 'repeater' as const, + publicKey: h.publicKey, + }; + } + return { + id: nextDraftHopId('custom', i), + kind: 'custom' as const, + hopHex: h.hopHex ?? '', + hopBytes: h.hopBytes ?? (1 as CustomHopBytes), + }; + }); + setDraftHops(hops); + + // Determine hop hash bytes from the loaded hops + const customHop = hops.find((h) => h.kind === 'custom'); + const hopHashBytes: CustomHopBytes = customHop?.hopBytes ?? 4; + + // Run the trace immediately + const runToken = activeRunTokenRef.current + 1; + activeRunTokenRef.current = runToken; + setLoading(true); + setError(null); + setResult(null); + try { + const traceResult = await onRunTracePath( + hopHashBytes, + hops.map((hop) => + hop.kind === 'repeater' ? { public_key: hop.publicKey } : { hop_hex: hop.hopHex } + ) + ); + if (activeRunTokenRef.current !== runToken) return; + setResult(traceResult); + + // Re-save to bump this trace to the top of recents + const savedTrace: SavedTrace = { hops: trace.hops, ranAt: Date.now() }; + saveRecentTrace(savedTrace); + setRecentTraces(loadRecentTraces()); + } catch (err) { + if (activeRunTokenRef.current !== runToken) return; + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + if (activeRunTokenRef.current === runToken) setLoading(false); + } + }; + const handleRunTrace = async () => { if (draftHops.length === 0) { return; @@ -292,6 +385,27 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps) return; } setResult(traceResult); + + // Persist to recent traces + const savedHops: SavedTraceHop[] = draftHops.map((hop) => { + if (hop.kind === 'repeater') { + const c = repeatersByKey.get(hop.publicKey); + return { + kind: 'repeater', + publicKey: hop.publicKey, + displayName: getContactDisplayName(c?.name, hop.publicKey, c?.last_advert ?? null), + }; + } + return { + kind: 'custom', + hopHex: hop.hopHex, + hopBytes: hop.hopBytes, + displayName: `${hop.hopHex.toUpperCase()} (${hop.hopBytes}B)`, + }; + }); + const trace: SavedTrace = { hops: savedHops, ranAt: Date.now() }; + saveRecentTrace(trace); + setRecentTraces(loadRecentTraces()); } catch (err) { if (activeRunTokenRef.current !== runToken) { return; @@ -453,6 +567,39 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)

The first node is display-only. The terminal node is the local radio.

+ {recentTraces.length > 0 && ( +
+
+ Rerun a recent trace: +
+
+ {recentTraces.map((trace, i) => { + const label = trace.hops + .map((h) => { + if (h.kind === 'repeater' && h.publicKey) { + const shortKey = h.publicKey.slice(0, 12); + return h.displayName !== shortKey + ? `${h.displayName} (${shortKey})` + : shortKey; + } + return h.displayName; + }) + .join(' → '); + return ( + + ); + })} +
+
+ )} {draftHops.length > 0 ? (