diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..0588772 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,51 @@ +name: pages + +# Builds the @openhop/web bundle in fragment mode (no API server, flows in +# the URL hash) and deploys to GitHub Pages. Repo Settings → Pages must be +# set to "Source: GitHub Actions" for the deploy to succeed. + +on: + push: + branches: [master] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# Only one concurrent deploy; cancel any in-progress run when a newer push +# lands so we don't ship a stale bundle on top of a fresh one. +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + - run: npm ci + - name: Build web (fragment mode) + run: npm run build -w @openhop/web + env: + VITE_BASE: /OpenHop/ + VITE_FRAGMENT_MODE: "1" + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v3 + with: + path: packages/web/dist + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/package-lock.json b/package-lock.json index b736ce9..2dd1c73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2503,6 +2503,16 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@types/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-s84fKOrzqqNCAPljhVyC5TjAo6BH4jKHw9NRNFNiRUY5QSgZCmVm5XILlWbisiKl+0OcS7eWihmKGS5akc2iQw==", + "deprecated": "This is a stub types definition. lz-string provides its own type definitions, so you do not need this installed.", + "dev": true, + "dependencies": { + "lz-string": "*" + } + }, "node_modules/@types/node": { "version": "24.12.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", @@ -5034,6 +5044,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -8579,6 +8598,7 @@ "@eslint/js": "^9.39.4", "@openhop/shared": "*", "@tailwindcss/vite": "^4.2.4", + "@types/lz-string": "^1.5.0", "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -8591,6 +8611,7 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", + "lz-string": "^1.5.0", "react": "^19.2.4", "react-dom": "^19.2.4", "tailwindcss": "^4.2.2", diff --git a/packages/web/__tests__/share-url.test.ts b/packages/web/__tests__/share-url.test.ts new file mode 100644 index 0000000..bbb8cb0 --- /dev/null +++ b/packages/web/__tests__/share-url.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { parseFlowYaml } from '@openhop/shared' +import { buildShareUrl, decodeFragment, encodeFragment } from '../src/lib/share-url' + +const SAMPLE_YAML = `meta: + title: Round-trip + path: demos +flow: + nodes: + - id: a + label: A + type: actor + - id: b + label: B + type: endpoint + steps: + - from: a + to: b + data: req +` + +describe('share-url encode/decode', () => { + it('round-trips a typical flow', () => { + const enc = encodeFragment(SAMPLE_YAML) + expect(enc).not.toContain(' ') + expect(enc).not.toContain('\n') + const dec = decodeFragment(enc) + expect(dec).toBe(SAMPLE_YAML) + // And the round-tripped YAML still validates against the schema. + expect(parseFlowYaml(dec ?? '').success).toBe(true) + }) + + it('compresses well — typical flow encodes to fewer than the raw byte count', () => { + // Locks in that the lz-string variant is actually denser than the raw + // bytes for representative input. (URL-safe base64 of raw text would be + // ~1.34× — lz-string typically wins by ~3× on YAML's repetitive shape.) + const enc = encodeFragment(SAMPLE_YAML) + expect(enc.length).toBeLessThan(SAMPLE_YAML.length) + }) + + it('decodes empty / missing fragment to null', () => { + expect(decodeFragment('')).toBeNull() + }) + + it('decodes garbage to null (caller renders the corrupted-link banner)', () => { + expect(decodeFragment('this-is-not-lz-encoded')).toBeNull() + expect(decodeFragment('!!!~~~')).toBeNull() + }) + + it('buildShareUrl uses Vite BASE_URL so dev (/) and Pages (/OpenHop/) both work', () => { + const dev = buildShareUrl(SAMPLE_YAML, 'http://localhost:8788', '/') + expect(dev).toMatch(/^http:\/\/localhost:8788\/#[A-Za-z0-9_+\-$.]+$/) + + const pages = buildShareUrl(SAMPLE_YAML, 'https://naorsabag.github.io', '/OpenHop/') + expect(pages).toMatch(/^https:\/\/naorsabag\.github\.io\/OpenHop\/#[A-Za-z0-9_+\-$.]+$/) + + // Hash content matches encodeFragment for both URLs (proves the BASE_URL + // only affects the path, never the encoded payload). + const expectedHash = encodeFragment(SAMPLE_YAML) + expect(dev.endsWith(`#${expectedHash}`)).toBe(true) + expect(pages.endsWith(`#${expectedHash}`)).toBe(true) + }) +}) diff --git a/packages/web/package.json b/packages/web/package.json index 5e6ff3e..ca55a65 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -32,6 +32,7 @@ "@eslint/js": "^9.39.4", "@openhop/shared": "*", "@tailwindcss/vite": "^4.2.4", + "@types/lz-string": "^1.5.0", "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -44,6 +45,7 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", + "lz-string": "^1.5.0", "react": "^19.2.4", "react-dom": "^19.2.4", "tailwindcss": "^4.2.2", diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 39d4305..c930ccf 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -300,10 +300,19 @@ function App() { isInSubFlowRef.current = isInSubFlow }, [isInSubFlow]) const handleCycleComplete = useCallback(() => { - if (!playingRef.current || !isInSubFlowRef.current) return - setTimeout(() => { - setFlowStack((prev) => (prev.length > 1 ? prev.slice(0, -1) : prev)) - }, 800) + if (!playingRef.current) return + if (isInSubFlowRef.current) { + // Sub-flow finished — pop back to the parent (which is still playing, + // resumes from `resumeFromStep`). + setTimeout(() => { + setFlowStack((prev) => (prev.length > 1 ? prev.slice(0, -1) : prev)) + }, 800) + } else { + // Root flow's cycle finished — stop playing so the header button flips + // back to "▶ Play". Otherwise the animation just loops indefinitely + // and the button is stuck on "⏸ Pause". + setPlaying(false) + } }, []) // Zoom transition when flow stack changes diff --git a/packages/web/src/AppFragment.tsx b/packages/web/src/AppFragment.tsx new file mode 100644 index 0000000..f5b4408 --- /dev/null +++ b/packages/web/src/AppFragment.tsx @@ -0,0 +1,417 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import YAML from 'yaml' +import { parseFlowYaml } from '@openhop/shared' +import { FlowCanvas } from './components/FlowCanvas' +import { + DataInspectionPanel, + InspectorToggle, + type DockSide, +} from './components/DataInspectionPanel' +import { FlowEditorModal } from './components/FlowEditorModal' +import { buildStarterYaml } from './lib/starter-yaml' +import { buildShareUrl, decodeFragment } from './lib/share-url' +import type { FlowNode, FlowStep, Flow } from './types' + +interface FlowNavItem { + flow: { nodes: FlowNode[]; steps?: FlowStep[] } + parentNodeId?: string + parentLabel?: string + resumeFromStep?: number +} + +/** + * Fragment-mode app shell — used by the GitHub Pages deploy. There is no API + * server, so: + * - The flow is decoded from `location.hash` (lz-compressed YAML). + * - "Save" doesn't POST anywhere; it builds a share URL and copies it to + * the clipboard. + * - There's no sidebar / flow list — Pages serves one flow per URL. + * + * The empty state ("no fragment, no flow") shows a "+ New flow" CTA. Invalid + * fragments surface as a banner with an empty editor below. + */ +export default function AppFragment() { + // Re-decode whenever the hash changes (deep-link, browser back, paste). + const [hash, setHash] = useState(() => window.location.hash.slice(1)) + useEffect(() => { + const onHash = () => setHash(window.location.hash.slice(1)) + window.addEventListener('hashchange', onHash) + return () => window.removeEventListener('hashchange', onHash) + }, []) + + // Decode → parse. Both layers' failures collapse into `decodeError` for + // the user-visible banner; the typed flow is `null` in the error case. + const { decodedFlow, decodeError } = useMemo<{ + decodedFlow: Flow | null + decodeError: string | null + }>(() => { + if (!hash) return { decodedFlow: null, decodeError: null } + const yaml = decodeFragment(hash) + if (yaml == null) { + return { + decodedFlow: null, + decodeError: + "This share link looks corrupted — couldn't decode the flow. Start a new one below.", + } + } + const result = parseFlowYaml(yaml) + if (!result.success) { + const first = result.errors[0] + return { + decodedFlow: null, + decodeError: `This share link decoded but doesn't validate: ${first?.path || '(root)'}: ${first?.message ?? 'unknown error'}.`, + } + } + return { + decodedFlow: { meta: result.data!.meta, flow: result.data!.flow } as Flow, + decodeError: null, + } + }, [hash]) + + // Editor modal state. + const [editor, setEditor] = useState< + | { mode: 'closed' } + | { mode: 'new'; initialYaml: string } + | { mode: 'edit'; initialYaml: string } + >({ mode: 'closed' }) + const [copying, setCopying] = useState(false) + const [toast, setToast] = useState(null) + const toastTimerRef = useRef(null) + + const showToast = useCallback((msg: string) => { + setToast(msg) + if (toastTimerRef.current !== null) window.clearTimeout(toastTimerRef.current) + toastTimerRef.current = window.setTimeout(() => setToast(null), 2400) + }, []) + + const handleNewFlow = useCallback(() => { + setEditor({ mode: 'new', initialYaml: buildStarterYaml() }) + }, []) + + const handleEditFlow = useCallback(() => { + if (!decodedFlow) { + setEditor({ mode: 'new', initialYaml: buildStarterYaml() }) + return + } + const yamlText = YAML.stringify({ meta: decodedFlow.meta, flow: decodedFlow.flow }) + setEditor({ mode: 'edit', initialYaml: yamlText }) + }, [decodedFlow]) + + // Save = build share URL + copy + update `location.hash` so the user sees + // their own edits live (and the URL bar reflects the canonical share form). + const handleEditorSave = useCallback( + async (yamlText: string) => { + setCopying(true) + try { + const url = buildShareUrl(yamlText, window.location.origin, import.meta.env.BASE_URL) + try { + await navigator.clipboard.writeText(url) + showToast('Copied share URL to clipboard.') + } catch { + // Some browsers / iframe contexts block clipboard. Fall through and + // still update the hash so the URL bar is the source of truth. + showToast('Could not copy automatically — copy from the URL bar.') + } + // Reflect the new flow in the URL hash so refresh / back / forward all work. + const hashOnly = url.split('#')[1] ?? '' + window.location.hash = hashOnly + } finally { + setCopying(false) + setEditor({ mode: 'closed' }) + } + }, + [showToast] + ) + + const handleEditorCancel = useCallback(() => setEditor({ mode: 'closed' }), []) + + // Flow stack / drilldown — same shape as App.tsx; only the data source differs. + const [playing, setPlaying] = useState(false) + const [flowStack, setFlowStack] = useState([]) + useEffect(() => { + setFlowStack([]) + setPlaying(false) + }, [hash]) + + const effectiveStack = useMemo(() => { + if (!decodedFlow) return [] + if (flowStack.length === 0) return [{ flow: decodedFlow.flow }] + return flowStack + }, [decodedFlow, flowStack]) + + const currentFlowBody = + effectiveStack.length > 0 ? effectiveStack[effectiveStack.length - 1] : null + + const displayFlow: Flow | null = useMemo(() => { + if (!decodedFlow || !currentFlowBody) return null + return { meta: decodedFlow.meta, flow: currentFlowBody.flow } as Flow + }, [decodedFlow, currentFlowBody]) + + const currentStepRef = useRef(0) + const [inspectedStep, setInspectedStep] = useState(null) + const displayFlowRef = useRef(displayFlow) + useEffect(() => { + displayFlowRef.current = displayFlow + }, [displayFlow]) + const handleStepChange = useCallback((stepIndex: number) => { + currentStepRef.current = stepIndex + const steps = displayFlowRef.current?.flow.steps ?? [] + if (steps[stepIndex]) setInspectedStep(steps[stepIndex]) + }, []) + + const [inspectorOpen, setInspectorOpen] = useState(true) + const [inspectorSide, setInspectorSide] = useState('right') + const [inspectorSize, setInspectorSize] = useState(320) + useEffect(() => setInspectedStep(null), [hash, flowStack.length]) + + const handleInspectStep = useCallback((step: FlowStep) => setInspectedStep(step), []) + const currentStep: FlowStep | null = useMemo(() => { + if (inspectedStep) return inspectedStep + const steps = currentFlowBody?.flow.steps ?? [] + return steps[0] ?? null + }, [inspectedStep, currentFlowBody]) + + const navigateToDrillDown = useCallback( + (nodeId: string, atStepIndex?: number) => { + if (!currentFlowBody || !decodedFlow) return + const node = currentFlowBody.flow.nodes.find((n) => n.id === nodeId) + if (!node?.flow) return + const resumeFrom = atStepIndex !== undefined ? atStepIndex + 1 : currentStepRef.current + 1 + setFlowStack((prev) => { + const base = prev.length === 0 ? [{ flow: decodedFlow.flow }] : prev + const updated = [...base] + updated[updated.length - 1] = { + ...updated[updated.length - 1], + resumeFromStep: resumeFrom, + } + return [...updated, { flow: node.flow!, parentNodeId: nodeId, parentLabel: node.label }] + }) + }, + [currentFlowBody, decodedFlow] + ) + + const handleDrillDown = useCallback( + (nodeId: string) => { + setPlaying(false) + navigateToDrillDown(nodeId) + }, + [navigateToDrillDown] + ) + const handleBack = useCallback(() => { + setPlaying(false) + setFlowStack((prev) => (prev.length > 1 ? prev.slice(0, -1) : prev)) + }, []) + const handleBreadcrumbNav = useCallback((index: number) => { + setPlaying(false) + setFlowStack((prev) => prev.slice(0, index + 1)) + }, []) + + const playingRef = useRef(playing) + useEffect(() => { + playingRef.current = playing + }, [playing]) + const handleAutoDrilldown = useCallback( + (nodeId: string, atStepIndex: number) => { + if (!playingRef.current) return + navigateToDrillDown(nodeId, atStepIndex) + }, + [navigateToDrillDown] + ) + const isInSubFlow = flowStack.length > 1 + const isInSubFlowRef = useRef(isInSubFlow) + useEffect(() => { + isInSubFlowRef.current = isInSubFlow + }, [isInSubFlow]) + const handleCycleComplete = useCallback(() => { + if (!playingRef.current) return + if (isInSubFlowRef.current) { + setTimeout(() => { + setFlowStack((prev) => (prev.length > 1 ? prev.slice(0, -1) : prev)) + }, 800) + } else { + // Root cycle complete — stop playing so the header button flips back + // to "▶ Play". Mirrors the same fix in App.tsx. + setPlaying(false) + } + }, []) + + return ( +
+
+
+

+ OpenHop +

+ share via URL +
+
+ + {decodedFlow && ( + <> + + + setInspectorOpen((o) => !o)} /> + + )} +
+
+ + {decodeError && ( +
+ {decodeError} +
+ )} + +
+
+
+ {!decodedFlow ? ( +
+
+

+ {decodeError ? 'No flow loaded' : 'No flow shared'} +

+

+ Open a flow by visiting a share URL, or click "+ New flow" above to create one. +

+
+
+ ) : displayFlow ? ( + <> + {effectiveStack.length > 1 && ( +
+ + +
+ )} + + + ) : null} +
+ {inspectorOpen && decodedFlow && ( + setInspectorOpen(false)} + /> + )} +
+
+ + + + {toast && ( +
+ {toast} +
+ )} +
+ ) +} diff --git a/packages/web/src/components/FlowEditorModal.tsx b/packages/web/src/components/FlowEditorModal.tsx index 80d9759..9f63340 100644 --- a/packages/web/src/components/FlowEditorModal.tsx +++ b/packages/web/src/components/FlowEditorModal.tsx @@ -18,6 +18,12 @@ export interface FlowEditorModalProps { serverError: MutationError | null onSave: (yamlText: string) => void onCancel: () => void + /** + * 'server' (default) — Save POSTs the YAML to /api/flows. + * 'fragment' — GitHub Pages deploy with no backend; Save copies the YAML + * into a sharable URL fragment and writes it to the clipboard. + */ + mode?: 'server' | 'fragment' } /** @@ -34,7 +40,9 @@ export function FlowEditorModal({ serverError, onSave, onCancel, + mode = 'server', }: FlowEditorModalProps) { + const isFragment = mode === 'fragment' const [text, setText] = useState(initialYaml || STARTER_YAML) const onSaveRef = useRef(onSave) const onCancelRef = useRef(onCancel) @@ -244,7 +252,9 @@ export function FlowEditorModal({ style={{ borderTop: '1px solid #2a2a4a', background: '#1a1a2e' }} > - ⌘/Ctrl-Enter to save · Esc to cancel + {isFragment + ? '⌘/Ctrl-Enter to copy URL · Esc to cancel' + : '⌘/Ctrl-Enter to save · Esc to cancel'} diff --git a/packages/web/src/hooks/useFlowAnimation.ts b/packages/web/src/hooks/useFlowAnimation.ts index a803857..05f12cf 100644 --- a/packages/web/src/hooks/useFlowAnimation.ts +++ b/packages/web/src/hooks/useFlowAnimation.ts @@ -109,18 +109,22 @@ export function useFlowAnimation( // Check if we've completed all steps if (rawNext >= steps.length) { + // Reset cycle state regardless of whether we'll loop OR fire a + // callback. Without resetting `stepIndexRef`, a consumer that pauses + // on cycle-complete (App.tsx / AppFragment.tsx) and then re-plays + // would re-enter advanceStep with `stepIndexRef.current` still at + // steps.length-1 — `rawNext` would again be >= steps.length, fire + // onCycleComplete a second time, and the Play button would flash + // back to "Play" instantly. Resetting here makes re-play clean. + stepIndexRef.current = -1 + nodeProgressRef.current = new Map() + activeNodesRef.current = new Set() + destroyedNodesRef.current = new Set() if (onCycleCompleteRef.current) { - // Don't loop — fire cycle complete callback - // But NOT here — this runs at the start of the NEXT advance - // The previous step's pixel might still be animating - // So just fire the callback and return onCycleCompleteRef.current() return } - // No callback — loop back to start - nodeProgressRef.current = new Map() - activeNodesRef.current = new Set() - destroyedNodesRef.current = new Set() + // No callback — fall through and loop back to step 0. } const nextIdx = rawNext % steps.length diff --git a/packages/web/src/lib/share-url.ts b/packages/web/src/lib/share-url.ts new file mode 100644 index 0000000..f2acd95 --- /dev/null +++ b/packages/web/src/lib/share-url.ts @@ -0,0 +1,37 @@ +import LZString from 'lz-string' + +/** + * URL-fragment encoding for the GitHub Pages deploy. + * + * The static deploy has no API, so a flow's full YAML lives in the URL hash: + * https://naorsabag.github.io/OpenHop/# + * + * `compressToEncodedURIComponent` is the LZ variant designed for URLs — its + * output is already safe to drop straight into a hash without further encoding. + * Decompresses to `null` on bad input (truncated link, foreign payload, etc.), + * which the app surfaces as a "share link looks corrupted" banner. + */ + +export function encodeFragment(yamlText: string): string { + return LZString.compressToEncodedURIComponent(yamlText) +} + +export function decodeFragment(fragment: string): string | null { + if (!fragment) return null + try { + const out = LZString.decompressFromEncodedURIComponent(fragment) + return out && out.length > 0 ? out : null + } catch { + return null + } +} + +/** + * Build the full sharable URL for a flow's YAML. Uses Vite's `BASE_URL` so the + * Pages deploy at `/OpenHop/` and dev at `/` both produce correct links. + */ +export function buildShareUrl(yamlText: string, origin: string, baseUrl: string): string { + const fragment = encodeFragment(yamlText) + // baseUrl ends with '/' (Vite convention), so concat without normalization. + return `${origin}${baseUrl}#${fragment}` +} diff --git a/packages/web/src/main.tsx b/packages/web/src/main.tsx index 2caec89..9dabba7 100644 --- a/packages/web/src/main.tsx +++ b/packages/web/src/main.tsx @@ -2,9 +2,15 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' +import AppFragment from './AppFragment.tsx' +// `VITE_FRAGMENT_MODE=1` switches the bundle into the GitHub Pages variant: +// no API server, flows live in the URL fragment, "Save" copies a share URL. +// Set in .github/workflows/pages.yml; unset for `npm run dev` and the +// docker-compose / openhop CLI deploys. Inlined in JSX (vs a named `Root` +// const) so we don't trip react-refresh/only-export-components — Fast +// Refresh requires component-shaped consts to be exported, and main.tsx +// is the entry point that shouldn't export. createRoot(document.getElementById('root')!).render( - - - + {import.meta.env.VITE_FRAGMENT_MODE === '1' ? : } ) diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts index c0f1f72..8a34f4f 100644 --- a/packages/web/vite.config.ts +++ b/packages/web/vite.config.ts @@ -5,6 +5,9 @@ import tailwindcss from '@tailwindcss/vite' // https://vite.dev/config/ export default defineConfig({ plugins: [tailwindcss(), react()], + // VITE_BASE='/OpenHop/' for the GitHub Pages deploy; '/' for dev. Set in + // .github/workflows/pages.yml so dev builds aren't affected. + base: process.env.VITE_BASE ?? '/', server: { host: '0.0.0.0', port: 8788,