Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,12 @@ for transaction-detail consumers. Captured workspace operations also check the
session token, so callbacks cannot borrow a later reopening of the same ID.
App supplies transient network admission separately from session validity:
cached transaction reads and saved spending links work without live admission.
Graph lookup reports completion through its existing `submit` intention rather
than requiring callers to infer success from loaded evidence. Workspace opens
Graph after completion, capturing the originating workbench for a contextual
return. The global linked-lookup banner clears only the reference that completed;
rejected, unavailable, cancelled and failed lookups leave it pending. Operation
admission and background preemption remain with `useWorkspaceOperation`.
Graph ancestry can reveal only saved parents while offline, without trying
missing transactions. Inspector navigation checks saved parents and exact spend
links across all saved transactions, independently of canvas membership; live
Expand Down
6 changes: 6 additions & 0 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,12 @@ immediately, even offline. The **Previous** selector beside the input loads one
or two levels of earlier transactions along with the lookup; it defaults to off
and both levels share a 500-transaction budget.

Links ending in `#?s=txid`, `#?s=txid:index` or `#?s=address` show a linked-lookup
banner and clear the reference from the address bar. Open or unlock a workspace,
then click the reference to add it and open Graph. The active workspace chooses
the network. If the lookup cannot complete, the banner stays so you can retry in
that workspace or switch to another. Use its dismiss button to discard the link.

With a transaction selected, **Load previous txs** adds one earlier level. For an
output, **Open creating tx** loads only the transaction that created it, and
**Find spending txs** looks for transactions that spent it (or any output of a
Expand Down
2 changes: 2 additions & 0 deletions src/App/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { TransactionFetchProvider } from './Workspace/TransactionFetchProvider';
import { ExamplesDialog } from './Examples/ExamplesDialog';
import { BookOpen, FolderOpen, Info, Library, Plus } from 'lucide-react';
import { ToastNotification } from './Controls/ToastNotification';
import { DeepLinkBanner } from './DeepLinkBanner';
import { Modal } from './Dialogs';
import { CreateWorkspaceDialog } from './Workspace/Dialogs/CreateWorkspaceDialog';
import { ImportWorkspaceDialog } from './Workspace/Dialogs/ImportWorkspaceDialog';
Expand Down Expand Up @@ -231,6 +232,7 @@ export default function App() {
</Modal>
)}
<ToastNotification app={app} workspace={workspace} />
<DeepLinkBanner app={app} workspace={workspace} />
<input
ref={fileInput}
type="file"
Expand Down
70 changes: 70 additions & 0 deletions src/App/DeepLinkBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { X } from 'lucide-react';
import { addressToScriptHash, type Network } from '../Core/Bitcoin';
import type { useAppState } from './useAppState';
import type { WorkspaceController } from './Workspace/useWorkspace';

interface DeepLinkBannerProps {
app: ReturnType<typeof useAppState>;
workspace: WorkspaceController;
}

function compactReference(ref: string) {
return ref.length > 24 ? `${ref.slice(0, 10)}…${ref.slice(-8)}` : ref;
}

function isAddressForNetwork(ref: string, network: Network) {
try {
addressToScriptHash(ref, network);
return true;
} catch {
return false;
}
}

/**
* Shows a lookup reference carried by a #?s= URL fragment until it is added to
* a graph or dismissed. The fragment itself is consumed on arrival; this
* banner is the only remaining holder of the reference.
*/
export function DeepLinkBanner({ app, workspace }: DeepLinkBannerProps) {
const ref = app.deepLinkRef;
if (!ref) return null;
const addToGraph = async () => {
if (!app.activeWorkspace) {
app.setNotice(
'Open or create a workspace first, then add the linked reference from the banner.',
);
return;
}
const valid =
/^[0-9a-f]{64}(:\d+)?$/i.test(ref) || isAddressForNetwork(ref, app.activeWorkspace.network);
if (!valid) {
app.setNotice('The link does not contain a valid transaction ID, output, or address.');
return;
}
if (await workspace.graph.lookup.submit(ref))
app.setDeepLinkRef((pending) => (pending === ref ? undefined : pending));
};
return (
<div className="deep-link-banner" data-testid="deep-link-banner" role="status">
<span className="deep-link-banner-label">Linked lookup</span>
<button
type="button"
className="deep-link-banner-ref mono"
data-testid="deep-link-banner-ref"
title={`Add ${ref} to the graph`}
onClick={() => void addToGraph()}
>
{compactReference(ref)}
</button>
<button
type="button"
className="icon-button"
aria-label="Dismiss linked lookup"
onClick={() => app.setDeepLinkRef(undefined)}
>
<X size={15} />
</button>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export interface GraphLookupFormProps {
onQueryError: (message: string) => void;
/** Resolves text that is already present in the workspace, so offline lookups stay enabled. */
resolveLoaded: (text: string) => string | undefined;
onSubmit: (text: string) => void | Promise<void>;
onSubmit: (text: string) => Promise<boolean>;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,12 @@ it.each([
);
});
expect(f.core.setOperation).not.toHaveBeenCalled();
let completed = false;
await act(async () => {
await f.result.current.lookup.submit(text);
completed = await f.result.current.lookup.submit(text);
await background;
});
expect(completed).toBe(true);
expect(backgroundSignal.aborted).toBe(true);
expect(f.reveal).toHaveBeenCalledWith(expected);
expect(f.result.current.state.resetToken).toBe(1);
Expand All @@ -111,7 +113,9 @@ it('keeps a cached lookup from changing selection during another foreground oper
});
});
});
await act(() => f.result.current.lookup.submit(TX_FUNDING));
await act(async () => {
expect(await f.result.current.lookup.submit(TX_FUNDING)).toBe(false);
});
expect(f.reveal).not.toHaveBeenCalled();
expect(f.observe).not.toHaveBeenCalled();
expect(f.result.current.state.resetToken).toBe(0);
Expand All @@ -121,7 +125,9 @@ it('keeps a cached lookup from changing selection during another foreground oper

it('still opens loaded evidence while live lookups are unavailable', async () => {
const f = fixture(true, false);
await act(() => f.result.current.lookup.submit(TX_FUNDING));
await act(async () => {
expect(await f.result.current.lookup.submit(TX_FUNDING)).toBe(true);
});
expect(f.reveal).toHaveBeenCalledWith(`tx:${TX_FUNDING}`);
expect(f.read).not.toHaveBeenCalled();
});
Expand All @@ -140,13 +146,13 @@ it('drops a lookup if selection changes while automatic work is yielding', async
{ background: true },
);
});
let lookup!: Promise<void>;
let lookup!: Promise<boolean>;
await act(async () => {
lookup = f.result.current.lookup.submit(TX_FUNDING);
f.selection.generation.current++;
release();
await background;
await lookup;
expect(await lookup).toBe(false);
});
expect(f.reveal).not.toHaveBeenCalled();
expect(f.observe).not.toHaveBeenCalled();
Expand Down
16 changes: 12 additions & 4 deletions src/App/Workspace/Workbenches/Graph/Navigation/useGraphLookup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,34 +44,40 @@ export function useGraphLookup({
const { transactions: recordTransactions } = transactions.observe;
const { run } = operation;

async function submit(text: string) {
if (!activeWorkspace || !text.trim()) return;
/** True only after an admitted lookup completes; failures use the operation's feedback. */
async function submit(text: string): Promise<boolean> {
if (!activeWorkspace || !text.trim()) return false;
text = text.trim();
if (/^(bc1|tb1)/i.test(text)) text = text.toLowerCase();
const submittedGeneration = selectionGeneration.current;
let completed = false;
await run(async (signal) => {
if (
activeWorkspaceRef.current?.id !== activeWorkspace.id ||
selectionGeneration.current !== submittedGeneration
)
return;
const existing = state.resolveLoaded(text);
if (!canLoadChainData && !existing)
throw new Error(
'Cannot add this reference: it is not loaded in this workspace and live lookup is unavailable.',
);
if (existing && !existing.startsWith('addr:')) {
recordTransactions(activeWorkspace.id, [], { promotionIds: [existing.split(':')[1]] });
reveal(existing);
if (!canLoadChainData || !prefetchDepth) {
state.clear();
completed = true;
return;
}
}
if (!/^[0-9a-f]{64}(:\d+)?$/i.test(text)) {
if (!canLoadChainData && !existing) return;
reveal(addressReference(text));
state.clear();
if (canLoadChainData) addressEvidence.startAddressHistoryLoad(text);
completed = true;
return;
}
if (!canLoadChainData) return;
const generation = selectionGeneration.current;
const [id, index] = text.split(':');
setOperation('Loading transaction…');
Expand Down Expand Up @@ -118,7 +124,9 @@ export function useGraphLookup({
setNotice(ancestryNotice(result));
}
state.clear();
completed = true;
});
return completed;
}

return { submit };
Expand Down
9 changes: 8 additions & 1 deletion src/App/Workspace/useWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,13 @@ export function useWorkspace(app: ReturnType<typeof useAppState>) {
return (next: WorkbenchMode, options?: WorkbenchSwitchOptions) =>
changeWorkbench(next, options, capturedOrigin);
}
async function submitGraphLookup(text: string) {
const showGraph = workbenchActionSwitch(workbench);
const completed = await graphLookup.submit(text);
if (completed)
showGraph('graph', { interaction: workbench === 'graph' ? 'switch' : 'handoff' });
return completed;
}
// The factory only creates event handlers; refs are read when an action runs, not during render.
// oxlint-disable-next-line react/refs
// What Graph publishes to the other workbenches, assembled once.
Expand Down Expand Up @@ -714,7 +721,7 @@ export function useWorkspace(app: ReturnType<typeof useAppState>) {
actions: graphActions,
address: graphAddress,
navigation: graphExpansion,
lookup: { ...lookup, submit: graphLookup.submit },
lookup: { ...lookup, submit: submitGraphLookup },
flowInputs,
scanTargets: connectionScanTargets,
},
Expand Down
1 change: 1 addition & 0 deletions src/App/component-styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ import './Workspace/Workbenches/Wallet/wallet-workbench.css';
import './Controls/multi-select-filter.css';
import './Workspace/Workbenches/workbenches.css';
import './Workspace/Workbenches/Graph/EntitiesPanel/entity-browser.css';
import './deeplink-banner.css';
39 changes: 39 additions & 0 deletions src/App/deepLink.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { parseLookupFragment } from './deepLink';

const txid = 'a'.repeat(64);
// Public BIP173 test vector.
const address = 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4';

describe('parseLookupFragment', () => {
it('reads a transaction id from #?s=', () => {
expect(parseLookupFragment(`#?s=${txid}`)).toBe(txid);
});

it('reads an outpoint from #?s=', () => {
expect(parseLookupFragment(`#?s=${txid}:0`)).toBe(`${txid}:0`);
});

it('reads an address from #?s=', () => {
expect(parseLookupFragment(`#?s=${address}`)).toBe(address);
});

it('keeps other parameters and returns the first s value', () => {
expect(parseLookupFragment(`#?s=${txid}&x=1`)).toBe(txid);
expect(parseLookupFragment(`#?s=${txid}&s=${'b'.repeat(64)}`)).toBe(txid);
});

it('trims surrounding whitespace', () => {
expect(parseLookupFragment(`#?s=%20${txid}%20`)).toBe(txid);
});

it('ignores in-app anchors and non-lookup fragments', () => {
expect(parseLookupFragment('#main-workspace')).toBeUndefined();
expect(parseLookupFragment('#graph-workspace')).toBeUndefined();
expect(parseLookupFragment('#')).toBeUndefined();
expect(parseLookupFragment('')).toBeUndefined();
expect(parseLookupFragment('#?foo=bar')).toBeUndefined();
expect(parseLookupFragment('#?s=')).toBeUndefined();
expect(parseLookupFragment('#?s=%20%09')).toBeUndefined();
});
});
9 changes: 9 additions & 0 deletions src/App/deepLink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/** Reads a lookup reference from a #?s= fragment, ignoring in-app hash anchors. */
export function parseLookupFragment(hash: string): string | undefined {
if (!hash.startsWith('#?')) return undefined;
const params = new URLSearchParams(hash.slice(2));
const value = params.get('s');
if (value === null) return undefined;
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
34 changes: 34 additions & 0 deletions src/App/deeplink-banner.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/* Global banner for #?s= deep-link references; the toast stays the feedback surface. */
.deep-link-banner {
position: fixed;
top: 68px;
right: 16px;
z-index: 60;
display: flex;
align-items: center;
gap: 8px;
max-width: min(420px, calc(100vw - 32px));
padding: 6px 8px;
background: var(--color-raised);
border: 1px solid var(--color-rule);
border-radius: var(--radius-sm);
font-size: var(--text-xs);
}
.deep-link-banner-label {
color: var(--color-muted);
white-space: nowrap;
}
.deep-link-banner-ref {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
min-height: 28px;
padding: 4px 8px;
color: var(--color-accent);
background: transparent;
border-color: transparent;
}
.deep-link-banner-ref:hover {
background: var(--color-hover);
border-color: var(--color-faint);
}
17 changes: 17 additions & 0 deletions src/App/useAppState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { DEMO_MODE } from '../buildMode';
import { useWorkspaces } from './Workspace/useWorkspaces';
import type { SavedWorkspace } from '../Core/Workspace/Persistence';
import { appServices } from './appServices';
import { parseLookupFragment } from './deepLink';
import {
createFeedback,
type Feedback,
Expand Down Expand Up @@ -68,6 +69,20 @@ export function useAppState() {
const [demoStartOpen, setDemoStartOpen] = useState(false);
const startWorkspace = () => (DEMO_MODE ? setDemoStartOpen(true) : setCreate('empty'));
const exampleNetworks = DEMO_MODE ? ['mainnet' as const] : discoveryError ? undefined : networks;
const [deepLinkRef, setDeepLinkRef] = useState<string>();
// A #?s= fragment is external navigation intent: consume it once and clear it
// from the address bar so the reference never re-enters history or logs.
useEffect(() => {
const consume = () => {
const ref = parseLookupFragment(window.location.hash);
if (ref === undefined) return;
window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}`);
setDeepLinkRef(ref);
};
consume();
window.addEventListener('hashchange', consume);
return () => window.removeEventListener('hashchange', consume);
}, []);
const [notice, setNoticeValue] = useState<Feedback>();
const [noticeSequence, setNoticeSequence] = useState(0);
const setNotice = useCallback<SetNotice>((feedback, options) => {
Expand Down Expand Up @@ -184,6 +199,8 @@ export function useAppState() {
setDeleteEntry,
examplesOpen,
setExamplesOpen,
deepLinkRef,
setDeepLinkRef,
notice,
setNotice,
noticeSequence,
Expand Down
Loading
Loading