From e1e0b48437df67dbefdd0bbdadc4696ebd3f9923 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Thu, 11 Jun 2026 21:12:24 -0700 Subject: [PATCH] Add 'Reverse Link' button to trace pane. Closes #287. --- frontend/src/components/TracePane.tsx | 57 +++++++++++++++++++++------ frontend/src/test/tracePane.test.tsx | 43 ++++++++++++++++++++ 2 files changed, 88 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/TracePane.tsx b/frontend/src/components/TracePane.tsx index 105e95d..0655f5f 100644 --- a/frontend/src/components/TracePane.tsx +++ b/frontend/src/components/TracePane.tsx @@ -318,6 +318,26 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps) clearPendingResult(); }; + // Append the reversed hop chain (minus the current endpoint) to build a return + // path, e.g. [R1, R2, R3] -> [R1, R2, R3, R2, R1]. A single hop is left as-is. + // See issue #287. Reverses every queued hop, including custom prefixes. + const handleReverseLink = () => { + setDraftHops((current) => { + if (current.length < 2) return current; + const returnHops = [...current] + .reverse() + .slice(1) + .map( + (hop, i): TraceDraftHop => ({ + ...hop, + id: nextDraftHopId(hop.kind, current.length + i), + }) + ); + return [...current, ...returnHops]; + }); + clearPendingResult(); + }; + const handleLoadRecentTrace = async (trace: SavedTrace) => { const hops: TraceDraftHop[] = trace.hops.map((h, i) => { if (h.kind === 'repeater' && h.publicKey) { @@ -616,18 +636,31 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps) )} {draftHops.length > 0 ? ( - +
+ + +
) : null}
diff --git a/frontend/src/test/tracePane.test.tsx b/frontend/src/test/tracePane.test.tsx index cbab1e9..df1322f 100644 --- a/frontend/src/test/tracePane.test.tsx +++ b/frontend/src/test/tracePane.test.tsx @@ -134,6 +134,49 @@ describe('TracePane', () => { expect(screen.getByText('No hops selected')).toBeInTheDocument(); }); + it('reverse link appends the reversed hop chain to build a return path (issue #287)', 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: [], + }) + ); + + render( + + ); + + // Single hop: Reverse link is a no-op (and disabled). + fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i })); + expect(screen.getByRole('button', { name: /reverse link/i })).toBeDisabled(); + + // R1, R2, R3 -> append R2, R1 => R1, R2, R3, R2, R1. + fireEvent.click(screen.getByRole('button', { name: /^add repeater relay beta/i })); + fireEvent.click(screen.getByRole('button', { name: /^add repeater relay charlie/i })); + fireEvent.click(screen.getByRole('button', { name: /reverse link/i })); + + expect(screen.getByText('5 hops selected ยท 4-byte trace')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /send trace/i })); + await waitFor(() => { + expect(onRunTracePath).toHaveBeenCalledWith(4, [ + { public_key: relayA.public_key }, + { public_key: relayB.public_key }, + { public_key: relayC.public_key }, + { public_key: relayB.public_key }, + { public_key: relayA.public_key }, + ]); + }); + }); + it('allows adding the same repeater multiple times from the picker row', () => { const relayA = makeContact('11'.repeat(32), 'Relay Alpha');