diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index b3ce436a..fb28314f 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -32,6 +32,7 @@ function guideItems(prefix: string) { { text: 'Security', link: `${prefix}/guide/security` }, { text: 'Standalone CLI', link: `${prefix}/guide/standalone-cli` }, { text: 'Hub', link: `${prefix}/guide/hub` }, + { text: 'Deep Linking', link: `${prefix}/guide/deep-linking` }, { text: 'Client Scripts & Context', link: `${prefix}/guide/client-context` }, { text: 'Agent-Native (experimental)', link: `${prefix}/guide/agent-native` }, ] satisfies DefaultTheme.NavItemWithLink[] diff --git a/docs/guide/deep-linking.md b/docs/guide/deep-linking.md new file mode 100644 index 00000000..b91b58b3 --- /dev/null +++ b/docs/guide/deep-linking.md @@ -0,0 +1,55 @@ +--- +outline: deep +--- + +# Deep Linking + +Send a user straight to a specific view inside a devframe — a particular terminal session, a particular data source — from another dock, an agent, or a copied URL. There are two paths: the hub relays a **dock activation** to focus a dock in place, and a standalone SPA reads its own **URL hash** to restore the view on load. + +## Focusing a dock inside a hub + +The viewer's active dock is client-local state — which dock is on screen lives in the shell page, not in shared state. A mounted devframe runs in its own iframe on its own RPC client, so it reaches that selection through the hub. `hub:docks:activate` switches the active dock and carries an opaque `params` bag the target dock reads: + +```ts +await rpc.call('hub:docks:activate', { + dockId: 'devframes:plugin:data-inspector', + params: { sourceId: 'my-plugin:store' }, +}) +``` + +The hub broadcasts the request live and mirrors it into the [`devframe:docks:active`](/guide/shared-state) shared-state slot, so a dock that mounts *because* of the switch still converges on the request instead of missing the broadcast. The target dock subscribes to that slot, filters on its own `dockId`, and reads the `params` field it recognizes — see [Cross-iframe dock activation](/guide/hub#cross-iframe-dock-activation) for the full relay. + +```mermaid +sequenceDiagram + participant Source as Other dock / agent + participant Hub + participant Slot as devframe:docks:active + participant Target as Target dock + Source->>Hub: hub:docks:activate { dockId, params } + Hub->>Slot: mirror latest activation + Hub-->>Target: switch active dock (if open) + Slot-->>Target: read on mount + on update + Target->>Target: params.dockId matches? focus params. +``` + +Focus is one-shot and tolerant: the [terminals dock](/plugins/terminals#focusing-a-session) reads `params.sessionId`, the [Data Inspector](/plugins/data-inspector#deep-linking) reads `params.sourceId`, and a target that names something not yet registered waits for it to appear, then fires once — the user's own clicks stay honored afterward. An id that never arrives is a no-op; a `dockId` the viewer doesn't know degrades to a warning ([DF8107](/errors/DF8107)). + +## Standalone URL deep links + +Running standalone — a CLI server, a static build — a devframe SPA owns its own URL. Encode the shareable view in the **hash** (`#…`): it round-trips through a copied link, survives a reload, and stays clear of the query string that the server handshake (`?devframe_auth_token=`) rides on. Parse it with `URLSearchParams` for a familiar key/value shape: + +```ts +// read on load +const params = new URLSearchParams(location.hash.replace(/^#/, '')) +const sourceId = params.get('source') + +// write back, without stacking history entries +history.replaceState(history.state, '', `#${params.toString()}`) + +// react to back/forward and manual edits +window.addEventListener('hashchange', applyState) +``` + +The [terminals dock](/plugins/terminals#deep-linking) keys a single selection as `#id=`; the [Data Inspector](/plugins/data-inspector#deep-linking) encodes its whole workbench — `#source=…&query=…` plus filter and auto-rerun flags — so a link reproduces an exact query result. Read the hash once at boot to restore the view, then keep it in sync as the user works. `replaceState` writes never fire `hashchange`, so the boot read, the live listener, and the write-back compose without looping. + +Keep credentials out of anything shareable. A pre-shared handshake token belongs in the query string and should be scrubbed from the address bar as soon as it's read, the way the Data Inspector consumes `?devframe_auth_token=` — never in the hash a user copies to share a view. diff --git a/docs/plugins/data-inspector.md b/docs/plugins/data-inspector.md index 8041a616..d4bb912c 100644 --- a/docs/plugins/data-inspector.md +++ b/docs/plugins/data-inspector.md @@ -10,7 +10,7 @@ Package: `@devframes/plugin-data-inspector` · framework: **Vue + Vite** ## What it does -- **Query workbench** — a CodeMirror jora editor with syntax highlighting and server-computed autocomplete; queries auto-run as you type, with a client-side syntax gate so malformed input never hits the wire. A toolbar copies the query, and the editor pairs with expand-all / collapse-all and copy-as-JSON controls over the results. Source, query, filters, and the auto-rerun setting persist in the URL, so any workbench state is shareable. +- **Query workbench** — a CodeMirror jora editor with syntax highlighting and server-computed autocomplete; queries auto-run as you type, with a client-side syntax gate so malformed input never hits the wire. A toolbar copies the query, and the editor pairs with expand-all / collapse-all and copy-as-JSON controls over the results. Source, query, filters, and the auto-rerun setting persist in the URL hash, so any workbench state is shareable (see [Deep linking](#deep-linking)). - **Auto rerun** — an optional poller under the filters (`auto rerun every N seconds`) re-runs the current query against the live object on a fixed period, so a value that changes over time updates on its own. Ticks are skipped while a run is in flight or the query is syntactically broken. - **Result viewer** — results normalize to strict JSON (circulars become `$ref` markers; Maps, Sets, class instances, functions, and Dates get type badges) with per-query stats: jora / normalize / rpc timings, payload size, node count. The value-actions popup copies paths and turns any key into a query. - **Lazy expansion** — deep graphs return one level at a time: a node past the depth cap renders a `load deeper` link that fetches just that subtree with a fresh budget and splices it in place, so a huge object stays responsive and loads on demand. @@ -70,6 +70,19 @@ ctx.services.whenAvailable('devframes:plugin:data-inspector:sources', (sources) > [!WARNING] > Queries are eval-grade access to registered objects: jora can invoke any function reachable as an own property and fires own getters. Register live objects with that in mind, and keep inspector endpoints on loopback. +## Deep linking + +The whole workbench state lives in the URL hash — `#source=&query=` plus the filter and auto-rerun flags — so a copied link reproduces an exact query result. It's read on load and kept in sync (via `replaceState`) as you work, and a `hashchange` listener re-applies it on back/forward and manual edits. The handshake token rides the query string (`?devframe_auth_token=`) and is scrubbed on read, so it never lands in a link you share. + +Mounted in a hub, another dock can jump the user straight to a source through [dock activation](../guide/deep-linking#focusing-a-dock-inside-a-hub) — an activation targeting `devframes:plugin:data-inspector` with a `sourceId` selects that source, waiting for it to register if it hasn't yet: + +```ts +await rpc.call('hub:docks:activate', { + dockId: 'devframes:plugin:data-inspector', + params: { sourceId: 'my-plugin:store' }, +}) +``` + ## Standalone ```sh diff --git a/docs/plugins/terminals.md b/docs/plugins/terminals.md index 3522abc1..52894fca 100644 --- a/docs/plugins/terminals.md +++ b/docs/plugins/terminals.md @@ -72,6 +72,10 @@ await rpc.call('hub:docks:activate', { It works whether the panel is already open (it reacts to the `devframe:docks:active` shared-state slot) or mounts in response to the switch (it reads the slot on start and converges). Focus is one-shot: an unknown or not-yet-arrived session id waits for that session to appear, and the user's own tab clicks are always honored afterward. A session id that never appears is a no-op — the default selection (most-recent session) stands. +## Deep linking + +Running standalone, the panel keeps the selected session in the URL hash as `#id=`: it selects that session on load if the id is live (otherwise the most-recent one), writes the hash with `replaceState` as tabs change, and honors back/forward and manual edits through a `hashchange` listener. So a copied link reopens on the same terminal. See the [deep-linking guide](/guide/deep-linking) for the pattern across devframes. + ## RPC surface All functions are namespaced `devframes:plugin:terminals:*`: diff --git a/plugins/data-inspector/src/spa/App.vue b/plugins/data-inspector/src/spa/App.vue index f4546dc5..59532610 100644 --- a/plugins/data-inspector/src/spa/App.vue +++ b/plugins/data-inspector/src/spa/App.vue @@ -9,7 +9,7 @@ import DataSourcePanel from './components/DataSourcePanel.vue' import QueryPanel from './components/QueryPanel.vue' import ResultViewer from './components/ResultViewer.vue' import SavedQueriesPanel from './components/SavedQueriesPanel.vue' -import { backend, connect, connection } from './composables/rpc' +import { backend, connect, connection, onDockActivation } from './composables/rpc' import { useSavedQueries } from './composables/saved' import { colorScheme } from './composables/scheme' import { useWorkbench, workbenchKey } from './composables/workbench' @@ -34,6 +34,9 @@ onMounted(async () => { await Promise.all([wb.loadSources(), savedApi.refresh()]) // Sources registered/unregistered after boot refresh the picker live. backend().onSourcesChanged(() => void wb.loadSources()) + // In a hub, another dock can deep-link straight to a source via dock + // activation (`hub:docks:activate` with `params.sourceId`); focus it. + void onDockActivation('devframes:plugin:data-inspector', id => wb.focusSource(id)) // An empty query runs `$`: the workbench lands on the full source object. void wb.runNow() void wb.loadSkeleton() diff --git a/plugins/data-inspector/src/spa/composables/rpc.ts b/plugins/data-inspector/src/spa/composables/rpc.ts index 3d9761f7..11d3e14d 100644 --- a/plugins/data-inspector/src/spa/composables/rpc.ts +++ b/plugins/data-inspector/src/spa/composables/rpc.ts @@ -69,6 +69,48 @@ export function backend(): DataBackend { return backendRef.value } +// ── dock activation (deep-link focus, hub only) ────────────────────── + +/** The hub mirrors the latest dock-activation intent into this shared-state slot. */ +const DOCKS_ACTIVE_STATE_KEY = 'devframe:docks:active' + +/** The live client, kept for the shared-state subscription below (rpc mode). */ +let rpcClient: DevframeRpcClient | null = null + +/** + * Subscribe to the hub's dock-activation slot. When an activation targets this + * dock (`dockId`) and carries a `sourceId`, invoke `apply` with it — the + * deep-link path that lets another dock (e.g. a messages feed) jump the user + * straight to a data source. Reads the slot once on subscribe (so a dock that + * mounts *because* of the activation still converges) and on every update. + * Inert outside a hub — static mode or no shared state simply never fires. + */ +export async function onDockActivation(dockId: string, apply: (sourceId: string) => void): Promise { + const client = rpcClient + if (!client) + return + interface Activation { dockId?: string, params?: Record } + const handle = (v: { activation?: Activation | null } | undefined): void => { + const activation = v?.activation + if (!activation || activation.dockId !== dockId) + return + const sourceId = activation.params?.sourceId + if (typeof sourceId === 'string') + apply(sourceId) + } + try { + const slot = await client.sharedState.get(DOCKS_ACTIVE_STATE_KEY, { initialValue: { activation: null } }) as { + value: () => { activation?: Activation | null } + on: (event: string, cb: (v: { activation?: Activation | null }) => void) => void + } + handle(slot.value()) + slot.on('updated', handle) + } + catch { + // No hub / no shared state — deep-linking simply stays inert. + } +} + // ── rpc backend ────────────────────────────────────────────────────── function createRpcBackend(client: DevframeRpcClient): DataBackend { @@ -221,6 +263,7 @@ export async function connect(): Promise { return } const client = await connectDevframe({ baseURL: './', authToken }) + rpcClient = client backendRef.value = createRpcBackend(client) applyStatus(client) client.events.on('connection:status', () => applyStatus(client)) diff --git a/plugins/data-inspector/src/spa/composables/workbench.ts b/plugins/data-inspector/src/spa/composables/workbench.ts index 3054d4fd..0a05ddab 100644 --- a/plugins/data-inspector/src/spa/composables/workbench.ts +++ b/plugins/data-inspector/src/spa/composables/workbench.ts @@ -64,9 +64,9 @@ function clampSeconds(value: number): number { return Math.min(MAX_AUTO_RERUN_SECONDS, Math.max(MIN_AUTO_RERUN_SECONDS, Math.round(value))) } -/** Read the shareable workbench state from the page URL. */ +/** Read the shareable workbench state from the page URL hash. */ function readUrlState(): { sourceId: string, query: string, filters: FilterOptions, autoRun: boolean, autoRunSeconds: number } { - const params = new URLSearchParams(location.search) + const params = new URLSearchParams(location.hash.replace(/^#/, '')) const filters: FilterOptions = {} for (const key of FILTER_KEYS) { if (params.get(key) === '1') @@ -120,6 +120,11 @@ export function useWorkbench() { const autoRunSeconds = ref(initial.autoRunSeconds) // ── URL persistence: source, query, and filters stay shareable ────── + // State lives in the hash (`#source=…&query=…`) so a copied link carries the + // whole workbench without touching the query string the server handshake + // (`?devframe_auth_token=`) rides on. `replaceState` keeps every keystroke + // out of the history stack; the write is guarded so a no-op change (e.g. the + // debounced write after a `hashchange` re-apply) never rewrites the URL. let urlTimer: ReturnType | undefined function syncUrl(): void { clearTimeout(urlTimer) @@ -137,11 +142,31 @@ export function useWorkbench() { params.set('autorun', '1') params.set('autorunSecs', String(autoRunSeconds.value)) } - const search = params.toString() - history.replaceState(null, '', search ? `?${search}` : location.pathname) + const hash = params.toString() + if (location.hash.replace(/^#/, '') === hash) + return + history.replaceState(history.state, '', hash ? `#${hash}` : location.pathname + location.search) }, URL_SYNC_DEBOUNCE) } + /** + * Re-apply the full workbench state from the URL hash — the live reaction to + * back/forward and manual address-bar edits (`hashchange`). Source is set + * first so its draft-restore runs before the shared query overwrites it; an + * unknown source id is left for `loadSources` to pick up. `replaceState` + * writes never fire `hashchange`, so this can't loop with `syncUrl`. + */ + function applyUrlState(): void { + const next = readUrlState() + if (next.sourceId && next.sourceId !== sourceId.value && sources.value.some(s => s.id === next.sourceId)) + sourceId.value = next.sourceId + query.value = next.query + for (const key of FILTER_KEYS) + settings[key] = next.filters[key] ?? false + autoRun.value = next.autoRun + autoRunSeconds.value = next.autoRunSeconds + } + const syntax = ref({ kind: 'ok' }) const running = ref(false) const serverError = ref(null) @@ -160,8 +185,16 @@ export function useWorkbench() { const activeSource = computed(() => sources.value.find(s => s.id === sourceId.value)) + // A dock-activation focus (`focusSource`) that names a source not yet + // registered waits here until `loadSources` sees it arrive — then fires once. + let pendingFocusId: string | null = null + async function loadSources(): Promise { sources.value = await backend().sources() + if (pendingFocusId && sources.value.some(s => s.id === pendingFocusId)) { + sourceId.value = pendingFocusId + pendingFocusId = null + } if (!sourceId.value || !sources.value.some(s => s.id === sourceId.value)) sourceId.value = sources.value[0]?.id ?? '' // A query arriving via the URL becomes the draft for its source, so the @@ -170,6 +203,21 @@ export function useWorkbench() { saveDraft() } + /** + * Select a source by id — the deep-link target of the hub's dock activation + * (`params.sourceId`). If the source isn't registered yet, remember it and + * converge the moment it appears (one-shot), mirroring the terminals dock. + */ + function focusSource(id: string): void { + if (sources.value.some(s => s.id === id)) { + sourceId.value = id + pendingFocusId = null + } + else { + pendingFocusId = id + } + } + // ── auto-run with syntax gate + stale-drop ───────────────────────── let runSeq = 0 let runTimer: ReturnType | undefined @@ -384,6 +432,10 @@ export function useWorkbench() { if (autoRun.value) restartAutoRerun() + // Live reaction: back/forward and manual hash edits re-apply the full state. + if (typeof window !== 'undefined') + window.addEventListener('hashchange', applyUrlState) + // ── query composition helpers ────────────────────────────────────── /** Set the query to a single top-level key (from the data-shape panel). */ function queryProp(key: string): void { @@ -427,6 +479,7 @@ export function useWorkbench() { skeletonLoading, loadSources, loadSkeleton, + focusSource, expandNode, runNow, requestSuggestions,