Skip to content

Commit 187c7d4

Browse files
feat: add deep linking support to the viewer
URL hash syncs with navigation state for shareable links: - Feature links: #/feature-slug - Scenario links: #/feature-slug/scenario-slug - Group links: #/group/path - Browser back/forward navigation supported - Deferred resolution for async data loading (WebSocket mode) - Skipped in embedded mode (VS Code controls navigation) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c0b78aa commit 187c7d4

File tree

3 files changed

+228
-0
lines changed

3 files changed

+228
-0
lines changed

packages/viewer/src/client/App.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { MainContent } from './components/MainContent';
33
import { useWebSocket } from './hooks/useWebSocket';
44
import { useVsCodeMessage } from './hooks/useVsCodeMessage';
55
import { useStaticData } from './hooks/useStaticData';
6+
import { useDeepLink } from './hooks/useDeepLink';
67

78
export default function App() {
89
// Hydrate store from embedded data when in static mode
@@ -11,6 +12,8 @@ export default function App() {
1112
useWebSocket(isStatic);
1213
// Listen for VS Code messages
1314
useVsCodeMessage();
15+
// Sync URL hash with navigation state (deep linking)
16+
useDeepLink();
1417

1518
return (
1619
<Layout>
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { useEffect, useRef, useState } from 'react';
2+
import { useStore } from '../store';
3+
import { isEmbedded } from '../config';
4+
import { buildHash, resolveHash } from '../lib/deep-link';
5+
6+
/**
7+
* Syncs the browser URL hash with the viewer's navigation state.
8+
* - Store → URL: navigation changes update the hash
9+
* - URL → Store: hash changes (back/forward, direct link) trigger navigation
10+
* - Skipped in embedded mode (VS Code controls navigation via postMessage)
11+
*/
12+
export function useDeepLink(): void {
13+
const { currentView, navigate, getCurrentRun, runs } = useStore();
14+
const suppressHashUpdate = useRef(false);
15+
const [embedded] = useState(() => isEmbedded());
16+
17+
// Capture the initial hash synchronously before any effects run
18+
const initialHash = useRef<string | undefined>(undefined);
19+
const initialResolved = useRef(false);
20+
if (initialHash.current === undefined) {
21+
const hash = typeof window !== 'undefined' ? window.location.hash : '';
22+
initialHash.current = (hash && hash !== '#' && hash !== '#/') ? hash : '';
23+
}
24+
25+
// ── Store → URL: update hash when navigation changes ──────────
26+
useEffect(() => {
27+
if (embedded) return;
28+
// Don't overwrite URL until the initial hash has been resolved
29+
if (initialHash.current && !initialResolved.current) return;
30+
31+
if (suppressHashUpdate.current) {
32+
suppressHashUpdate.current = false;
33+
return;
34+
}
35+
36+
const run = getCurrentRun();
37+
const hash = buildHash(currentView, run);
38+
const currentHash = window.location.hash;
39+
40+
// Only update if hash actually changed (avoid pushing duplicate history)
41+
if (hash !== currentHash && !(hash === '' && currentHash === '')) {
42+
window.history.pushState(null, '', hash || window.location.pathname + window.location.search);
43+
}
44+
}, [embedded, currentView, getCurrentRun]);
45+
46+
// ── Resolve initial hash when run data becomes available ──────
47+
useEffect(() => {
48+
if (embedded || !initialHash.current || initialResolved.current) return;
49+
50+
const run = getCurrentRun();
51+
if (!run) return; // Data not loaded yet — wait
52+
53+
const resolved = resolveHash(initialHash.current, run);
54+
initialResolved.current = true;
55+
56+
if (resolved) {
57+
suppressHashUpdate.current = true;
58+
navigate(resolved.type, resolved.id);
59+
}
60+
}, [embedded, runs, getCurrentRun, navigate]);
61+
62+
// ── Handle browser back/forward ───────────────────────────────
63+
useEffect(() => {
64+
if (embedded) return;
65+
66+
function onPopState() {
67+
const hash = window.location.hash;
68+
const run = getCurrentRun();
69+
const resolved = resolveHash(hash, run);
70+
71+
if (resolved) {
72+
suppressHashUpdate.current = true;
73+
navigate(resolved.type, resolved.id);
74+
}
75+
}
76+
77+
window.addEventListener('popstate', onPopState);
78+
return () => window.removeEventListener('popstate', onPopState);
79+
}, [embedded, getCurrentRun, navigate]);
80+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import type { Run } from '../store';
2+
import type { TestCase, AnyTest } from '@swedevtools/livedoc-schema';
3+
4+
/**
5+
* Converts a title string to a URL-friendly slug.
6+
* e.g. "Browser launches & provides valid Playwright objects" → "browser-launches-provides-valid-playwright-objects"
7+
*/
8+
export function toSlug(title: string): string {
9+
return title
10+
.toLowerCase()
11+
.replace(/['']/g, '') // remove apostrophes
12+
.replace(/[^a-z0-9]+/g, '-') // replace non-alphanumeric runs with hyphens
13+
.replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens
14+
}
15+
16+
// ── Build hash from current view state ──────────────────────────
17+
18+
export interface DeepLinkView {
19+
type: 'summary' | 'node' | 'group';
20+
id?: string;
21+
}
22+
23+
/**
24+
* Builds a URL hash string from the current navigation state.
25+
* Returns '' for the summary (home) view.
26+
*/
27+
export function buildHash(view: DeepLinkView, run: Run | undefined): string {
28+
if (!run || view.type === 'summary') return '';
29+
30+
if (view.type === 'group' && view.id) {
31+
// Group ids are "group:path/segments" — extract the path
32+
const path = view.id.replace(/^group:/, '');
33+
return `#/group/${path}`;
34+
}
35+
36+
if (view.type === 'node' && view.id) {
37+
const item = run.itemById[view.id];
38+
if (!item) return '';
39+
40+
// Check if this is a top-level document (TestCase/Feature/Specification)
41+
const doc = findDocumentForItem(run, view.id);
42+
if (!doc) return '';
43+
44+
if (doc.id === view.id) {
45+
// Navigating to a document itself
46+
return `#/${toSlug(doc.title)}`;
47+
}
48+
49+
// Navigating to a child test within a document
50+
return `#/${toSlug(doc.title)}/${toSlug(item.title)}`;
51+
}
52+
53+
return '';
54+
}
55+
56+
// ── Resolve hash back to a view ─────────────────────────────────
57+
58+
export interface ResolvedView {
59+
type: 'summary' | 'node' | 'group';
60+
id?: string;
61+
}
62+
63+
/**
64+
* Parses a URL hash and resolves it to a navigation view.
65+
* Returns null if the hash can't be resolved (item not found).
66+
*/
67+
export function resolveHash(hash: string, run: Run | undefined): ResolvedView | null {
68+
if (!hash || hash === '#' || hash === '#/') {
69+
return { type: 'summary' };
70+
}
71+
72+
// Strip leading #/
73+
const path = hash.replace(/^#\/?/, '');
74+
if (!path) return { type: 'summary' };
75+
76+
// Group paths: /group/path/segments
77+
if (path.startsWith('group/')) {
78+
const groupPath = path.slice('group/'.length);
79+
return { type: 'group', id: `group:${groupPath}` };
80+
}
81+
82+
if (!run) return null; // Data not loaded yet
83+
84+
const segments = path.split('/').filter(Boolean);
85+
86+
if (segments.length === 1) {
87+
// Document-level slug
88+
const docSlug = segments[0];
89+
const doc = findDocumentBySlug(run, docSlug);
90+
if (doc) return { type: 'node', id: doc.id };
91+
return null;
92+
}
93+
94+
if (segments.length === 2) {
95+
// Document/test slug
96+
const [docSlug, testSlug] = segments;
97+
const doc = findDocumentBySlug(run, docSlug);
98+
if (!doc) return null;
99+
100+
const test = findTestBySlug(doc, testSlug);
101+
if (test) return { type: 'node', id: test.id };
102+
return null;
103+
}
104+
105+
return null;
106+
}
107+
108+
// ── Lookup helpers ──────────────────────────────────────────────
109+
110+
function findDocumentForItem(run: Run, itemId: string): TestCase | undefined {
111+
for (const doc of run.run.documents ?? []) {
112+
if (doc.id === itemId) return doc;
113+
if (hasDescendant(doc, itemId)) return doc;
114+
}
115+
return undefined;
116+
}
117+
118+
function hasDescendant(doc: TestCase, itemId: string): boolean {
119+
for (const test of doc.tests ?? []) {
120+
if (test.id === itemId) return true;
121+
if ('steps' in test && Array.isArray((test as any).steps)) {
122+
for (const step of (test as any).steps as AnyTest[]) {
123+
if (step.id === itemId) return true;
124+
}
125+
}
126+
}
127+
return false;
128+
}
129+
130+
function findDocumentBySlug(run: Run, slug: string): TestCase | undefined {
131+
return (run.run.documents ?? []).find(doc => toSlug(doc.title) === slug);
132+
}
133+
134+
function findTestBySlug(doc: TestCase, slug: string): AnyTest | undefined {
135+
for (const test of doc.tests ?? []) {
136+
if (toSlug(test.title) === slug) return test;
137+
// Check nested steps/rules
138+
if ('steps' in test && Array.isArray((test as any).steps)) {
139+
for (const step of (test as any).steps as AnyTest[]) {
140+
if (toSlug(step.title) === slug) return step;
141+
}
142+
}
143+
}
144+
return undefined;
145+
}

0 commit comments

Comments
 (0)