diff --git a/frontend/src/components/TracePane.tsx b/frontend/src/components/TracePane.tsx index 0655f5f..2d90a96 100644 --- a/frontend/src/components/TracePane.tsx +++ b/frontend/src/components/TracePane.tsx @@ -26,11 +26,14 @@ import { import { Input } from './ui/input'; import { cn } from '@/lib/utils'; -type TraceSortMode = 'alpha' | 'recent' | 'distance'; +type TraceSortMode = 'alpha' | 'recent' | 'distance' | 'traced'; type CustomHopBytes = 1 | 2 | 4; const RECENT_TRACES_KEY = 'remoteterm-recent-traces'; const MAX_RECENT_TRACES = 5; +const RECENT_NODES_KEY = 'remoteterm-recent-trace-nodes'; +const MAX_RECENT_NODES = 30; +const MAX_RENDERED_REPEATERS = 60; interface SavedTraceHop { kind: 'repeater' | 'custom'; @@ -71,6 +74,57 @@ function saveRecentTrace(trace: SavedTrace): void { } } +function repeaterKeysFromHops(hops: SavedTraceHop[]): string[] { + return [ + ...new Set( + hops + .filter((hop) => hop.kind === 'repeater' && hop.publicKey) + .map((hop) => hop.publicKey as string) + ), + ]; +} + +function loadRecentNodeKeys(): string[] { + try { + const raw = localStorage.getItem(RECENT_NODES_KEY); + if (raw) { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return [ + ...new Set( + parsed + .map((entry) => + typeof entry === 'string' ? entry : ((entry?.publicKey as string) ?? null) + ) + .filter((key): key is string => typeof key === 'string' && key.length > 0) + ), + ].slice(0, MAX_RECENT_NODES); + } + // No usage history yet: seed from already-stored recent traces so the + // Recent Traced sort works immediately for users with existing history. + return repeaterKeysFromHops(loadRecentTraces().flatMap((trace) => trace.hops)).slice( + 0, + MAX_RECENT_NODES + ); + } catch { + return []; + } +} + +function saveRecentNodeKeys(hops: SavedTraceHop[]): void { + try { + // MRU order: repeaters from this trace first, then prior history. + const fresh = repeaterKeysFromHops(hops); + const rest = loadRecentNodeKeys().filter((key) => !fresh.includes(key)); + localStorage.setItem( + RECENT_NODES_KEY, + JSON.stringify([...fresh, ...rest].slice(0, MAX_RECENT_NODES)) + ); + } catch { + // localStorage may be disabled + } +} + type TraceDraftHop = | { id: string; kind: 'repeater'; publicKey: string } | { id: string; kind: 'custom'; hopHex: string; hopBytes: CustomHopBytes }; @@ -200,6 +254,7 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps) const [customHopError, setCustomHopError] = useState(null); const [recentTraces, setRecentTraces] = useState(loadRecentTraces); const [recentTracesOpen, setRecentTracesOpen] = useState(false); + const [recentNodeKeys, setRecentNodeKeys] = useState(loadRecentNodeKeys); const activeRunTokenRef = useRef(0); const repeaters = useMemo(() => { @@ -220,9 +275,16 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps) [repeaters] ); + const tracedIndexByKey = useMemo( + () => new Map(recentNodeKeys.map((key, index) => [key, index])), + [recentNodeKeys] + ); + + const canSortByDistance = !!config && isValidLocation(config.lat, config.lon); + const filteredRepeaters = useMemo(() => { const query = searchQuery.trim().toLowerCase(); - const matching = query + let matching = query ? repeaters.filter( (contact) => (contact.name ?? '').toLowerCase().includes(query) || @@ -230,7 +292,27 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps) ) : repeaters; + // Traced shows only repeaters actually used in traces; Dist. shows only + // repeaters with a computable distance (when the local radio has one). + if (sortMode === 'traced') { + matching = matching.filter((contact) => tracedIndexByKey.has(contact.public_key)); + } + const distanceByKey = + sortMode === 'distance' + ? new Map(matching.map((contact) => [contact.public_key, getDistanceKm(contact, config)])) + : null; + if (distanceByKey && canSortByDistance) { + matching = matching.filter((contact) => distanceByKey.get(contact.public_key) !== null); + } + return [...matching].sort((left, right) => { + if (sortMode === 'traced') { + const leftIndex = tracedIndexByKey.get(left.public_key) ?? Infinity; + const rightIndex = tracedIndexByKey.get(right.public_key) ?? Infinity; + if (leftIndex !== rightIndex) { + return leftIndex - rightIndex; + } + } if (sortMode === 'recent') { const leftTs = getHeardTimestamp(left); const rightTs = getHeardTimestamp(right); @@ -238,9 +320,9 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps) return rightTs - leftTs; } } - if (sortMode === 'distance') { - const leftDistance = getDistanceKm(left, config); - const rightDistance = getDistanceKm(right, config); + if (distanceByKey) { + const leftDistance = distanceByKey.get(left.public_key) ?? null; + const rightDistance = distanceByKey.get(right.public_key) ?? null; if (leftDistance !== null && rightDistance !== null && leftDistance !== rightDistance) { return leftDistance - rightDistance; } @@ -251,11 +333,15 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps) getContactDisplayName(right.name, right.public_key, right.last_advert) ); }); - }, [config, repeaters, searchQuery, sortMode]); + }, [canSortByDistance, config, repeaters, searchQuery, sortMode, tracedIndexByKey]); + + const visibleRepeaters = useMemo( + () => filteredRepeaters.slice(0, MAX_RENDERED_REPEATERS), + [filteredRepeaters] + ); const localRadioName = config?.name || 'Local radio'; const localRadioKey = config?.public_key ?? null; - const canSortByDistance = !!config && isValidLocation(config.lat, config.lon); const customHopBytesLocked = useMemo( () => draftHops.find((hop) => hop.kind === 'custom')?.hopBytes ?? null, [draftHops] @@ -338,6 +424,13 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps) clearPendingResult(); }; + const recordTraceRun = (hops: SavedTraceHop[]) => { + saveRecentTrace({ hops, ranAt: Date.now() }); + setRecentTraces(loadRecentTraces()); + saveRecentNodeKeys(hops); + setRecentNodeKeys(loadRecentNodeKeys()); + }; + const handleLoadRecentTrace = async (trace: SavedTrace) => { const hops: TraceDraftHop[] = trace.hops.map((h, i) => { if (h.kind === 'repeater' && h.publicKey) { @@ -376,10 +469,8 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps) 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()); + // Re-save to bump this trace and its nodes to the top of recents + recordTraceRun(trace.hops); } catch (err) { if (activeRunTokenRef.current !== runToken) return; setError(err instanceof Error ? err.message : 'Unknown error'); @@ -426,9 +517,7 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps) displayName: `${hop.hopHex.toUpperCase()} (${hop.hopBytes}B)`, }; }); - const trace: SavedTrace = { hops: savedHops, ranAt: Date.now() }; - saveRecentTrace(trace); - setRecentTraces(loadRecentTraces()); + recordTraceRun(savedHops); } catch (err) { if (activeRunTokenRef.current !== runToken) { return; @@ -490,16 +579,18 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
{( [ - ['alpha', 'Alpha'], - ['recent', 'Recent Heard'], - ['distance', 'Distance'], + ['alpha', 'A/Z', 'Sort alphabetically'], + ['recent', 'Heard', 'Most recently heard first'], + ['traced', 'Traced', 'Most recently used in traces first'], + ['distance', 'Dist.', 'Closest first'], ] as const - ).map(([value, label]) => ( + ).map(([value, label, description]) => (
diff --git a/frontend/src/test/tracePane.test.tsx b/frontend/src/test/tracePane.test.tsx index df1322f..288a7be 100644 --- a/frontend/src/test/tracePane.test.tsx +++ b/frontend/src/test/tracePane.test.tsx @@ -1,5 +1,5 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { TracePane } from '../components/TracePane'; import type { Contact, RadioConfig, RadioTraceResponse } from '../types'; @@ -45,6 +45,10 @@ const config: RadioConfig = { }; describe('TracePane', () => { + beforeEach(() => { + localStorage.clear(); + }); + it('shows only full-key repeaters and filters by name or key', () => { render( { expect(screen.getByText(/custom hops are locked to 1-byte prefixes/i)).toBeInTheDocument(); }); + it('Traced lists only trace-used repeaters in MRU order, persisted locally (issue #286)', async () => { + const relayA = makeContact('11'.repeat(32), 'Relay Alpha'); + const relayB = makeContact('22'.repeat(32), 'Relay Beta'); + const relayC = makeContact('33'.repeat(32), 'Relay Charlie'); + const onRunTracePath = vi.fn( + async (): Promise => ({ + path_len: 0, + timeout_seconds: 6, + nodes: [], + }) + ); + + const { unmount } = render( + + ); + + const rowNames = () => + screen + .queryAllByRole('button', { name: /^add repeater/i }) + .map((row) => row.getAttribute('aria-label')); + + // No history yet: Traced shows an explanatory empty state, not the full list. + fireEvent.click(screen.getByRole('button', { name: 'Traced' })); + expect(screen.getByText(/no repeaters have been used in traces yet/i)).toBeInTheDocument(); + expect(rowNames()).toEqual([]); + + // Build and run a trace with B from the A/Z list. + fireEvent.click(screen.getByRole('button', { name: 'A/Z' })); + fireEvent.click(screen.getByRole('button', { name: /^add repeater relay beta/i })); + fireEvent.click(screen.getByRole('button', { name: /send trace/i })); + await waitFor(() => expect(onRunTracePath).toHaveBeenCalledTimes(1)); + + // Traced lists only B; untraced A and C are filtered out. + fireEvent.click(screen.getByRole('button', { name: 'Traced' })); + expect(rowNames()).toEqual(['Add repeater Relay Beta']); + + // A second trace with C bumps it above B. + fireEvent.click(screen.getByRole('button', { name: 'A/Z' })); + fireEvent.click(screen.getByRole('button', { name: /remove relay beta/i })); + fireEvent.click(screen.getByRole('button', { name: /^add repeater relay charlie/i })); + fireEvent.click(screen.getByRole('button', { name: /send trace/i })); + await waitFor(() => expect(onRunTracePath).toHaveBeenCalledTimes(2)); + + fireEvent.click(screen.getByRole('button', { name: 'Traced' })); + expect(rowNames()).toEqual(['Add repeater Relay Charlie', 'Add repeater Relay Beta']); + + // Order persists across remounts via localStorage. + unmount(); + render( + + ); + fireEvent.click(screen.getByRole('button', { name: 'Traced' })); + expect(rowNames()).toEqual(['Add repeater Relay Charlie', 'Add repeater Relay Beta']); + }); + + it('seeds Traced from stored recent traces when no usage history exists', () => { + const relayA = makeContact('11'.repeat(32), 'Relay Alpha'); + const relayB = makeContact('22'.repeat(32), 'Relay Beta'); + localStorage.setItem( + 'remoteterm-recent-traces', + JSON.stringify([ + { + ranAt: 1, + hops: [ + { kind: 'repeater', publicKey: relayB.public_key, displayName: 'Relay Beta' }, + { kind: 'custom', hopHex: 'ae', hopBytes: 1, displayName: 'AE (1B)' }, + ], + }, + ]) + ); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Traced' })); + expect( + screen + .getAllByRole('button', { name: /^add repeater/i }) + .map((row) => row.getAttribute('aria-label')) + ).toEqual(['Add repeater Relay Beta']); + }); + + it('Dist. hides repeaters without a known distance when the radio has a location', () => { + const located = makeContact('11'.repeat(32), 'Relay Located', CONTACT_TYPE_REPEATER, { + lat: 10.1, + lon: 20.1, + }); + const unlocated = makeContact('22'.repeat(32), 'Relay Mystery'); + + render(); + + // A/Z shows both. + expect(screen.getByText('Relay Located')).toBeInTheDocument(); + expect(screen.getByText('Relay Mystery')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Dist.' })); + expect(screen.getByText('Relay Located')).toBeInTheDocument(); + expect(screen.queryByText('Relay Mystery')).not.toBeInTheDocument(); + + // Without a local radio location, the filter is skipped (note explains instead). + fireEvent.change(screen.getByLabelText('Search repeaters'), { target: { value: 'mystery' } }); + expect(screen.getByText(/no repeaters with a known distance matched/i)).toBeInTheDocument(); + }); + + it('caps the rendered repeater list and reports the overflow', () => { + const contacts = Array.from({ length: 70 }, (_, i) => + makeContact(i.toString(16).padStart(2, '0').repeat(32), `Relay ${String(i).padStart(3, '0')}`) + ); + + render(); + + expect(screen.getAllByRole('button', { name: /^add repeater/i })).toHaveLength(60); + expect(screen.getByText(/showing the first 60 of 70 repeaters/i)).toBeInTheDocument(); + }); + it('drops an in-flight result after the draft path changes', async () => { const relayA = makeContact('11'.repeat(32), 'Relay Alpha'); const relayB = makeContact('22'.repeat(32), 'Relay Beta');