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
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down
55 changes: 55 additions & 0 deletions docs/guide/deep-linking.md
Original file line number Diff line number Diff line change
@@ -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.<key>
```

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=<sessionId>`; 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.
15 changes: 14 additions & 1 deletion docs/plugins/data-inspector.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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=<id>&query=<jora>` 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
Expand Down
4 changes: 4 additions & 0 deletions docs/plugins/terminals.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<sessionId>`: 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:*`:
Expand Down
5 changes: 4 additions & 1 deletion plugins/data-inspector/src/spa/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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()
Expand Down
43 changes: 43 additions & 0 deletions plugins/data-inspector/src/spa/composables/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const client = rpcClient
if (!client)
return
interface Activation { dockId?: string, params?: Record<string, unknown> }
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 {
Expand Down Expand Up @@ -221,6 +263,7 @@ export async function connect(): Promise<void> {
return
}
const client = await connectDevframe({ baseURL: './', authToken })
rpcClient = client
backendRef.value = createRpcBackend(client)
applyStatus(client)
client.events.on('connection:status', () => applyStatus(client))
Expand Down
61 changes: 57 additions & 4 deletions plugins/data-inspector/src/spa/composables/workbench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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<typeof setTimeout> | undefined
function syncUrl(): void {
clearTimeout(urlTimer)
Expand All @@ -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<SyntaxState>({ kind: 'ok' })
const running = ref(false)
const serverError = ref<string | null>(null)
Expand All @@ -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<void> {
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
Expand All @@ -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<typeof setTimeout> | undefined
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -427,6 +479,7 @@ export function useWorkbench() {
skeletonLoading,
loadSources,
loadSkeleton,
focusSource,
expandNode,
runNow,
requestSuggestions,
Expand Down
Loading