Skip to content

Commit 228802b

Browse files
authored
feat(data-inspector): deep link to a data source (#152)
1 parent e696af6 commit 228802b

7 files changed

Lines changed: 178 additions & 6 deletions

File tree

docs/.vitepress/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ function guideItems(prefix: string) {
3232
{ text: 'Security', link: `${prefix}/guide/security` },
3333
{ text: 'Standalone CLI', link: `${prefix}/guide/standalone-cli` },
3434
{ text: 'Hub', link: `${prefix}/guide/hub` },
35+
{ text: 'Deep Linking', link: `${prefix}/guide/deep-linking` },
3536
{ text: 'Client Scripts & Context', link: `${prefix}/guide/client-context` },
3637
{ text: 'Agent-Native (experimental)', link: `${prefix}/guide/agent-native` },
3738
] satisfies DefaultTheme.NavItemWithLink[]

docs/guide/deep-linking.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# Deep Linking
6+
7+
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.
8+
9+
## Focusing a dock inside a hub
10+
11+
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:
12+
13+
```ts
14+
await rpc.call('hub:docks:activate', {
15+
dockId: 'devframes:plugin:data-inspector',
16+
params: { sourceId: 'my-plugin:store' },
17+
})
18+
```
19+
20+
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.
21+
22+
```mermaid
23+
sequenceDiagram
24+
participant Source as Other dock / agent
25+
participant Hub
26+
participant Slot as devframe:docks:active
27+
participant Target as Target dock
28+
Source->>Hub: hub:docks:activate { dockId, params }
29+
Hub->>Slot: mirror latest activation
30+
Hub-->>Target: switch active dock (if open)
31+
Slot-->>Target: read on mount + on update
32+
Target->>Target: params.dockId matches? focus params.<key>
33+
```
34+
35+
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)).
36+
37+
## Standalone URL deep links
38+
39+
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:
40+
41+
```ts
42+
// read on load
43+
const params = new URLSearchParams(location.hash.replace(/^#/, ''))
44+
const sourceId = params.get('source')
45+
46+
// write back, without stacking history entries
47+
history.replaceState(history.state, '', `#${params.toString()}`)
48+
49+
// react to back/forward and manual edits
50+
window.addEventListener('hashchange', applyState)
51+
```
52+
53+
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.
54+
55+
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.

docs/plugins/data-inspector.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Package: `@devframes/plugin-data-inspector` · framework: **Vue + Vite**
1010

1111
## What it does
1212

13-
- **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.
13+
- **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)).
1414
- **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.
1515
- **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.
1616
- **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)
7070
> [!WARNING]
7171
> 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.
7272
73+
## Deep linking
74+
75+
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.
76+
77+
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:
78+
79+
```ts
80+
await rpc.call('hub:docks:activate', {
81+
dockId: 'devframes:plugin:data-inspector',
82+
params: { sourceId: 'my-plugin:store' },
83+
})
84+
```
85+
7386
## Standalone
7487

7588
```sh

docs/plugins/terminals.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ await rpc.call('hub:docks:activate', {
7272

7373
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.
7474

75+
## Deep linking
76+
77+
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.
78+
7579
## RPC surface
7680

7781
All functions are namespaced `devframes:plugin:terminals:*`:

plugins/data-inspector/src/spa/App.vue

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import DataSourcePanel from './components/DataSourcePanel.vue'
99
import QueryPanel from './components/QueryPanel.vue'
1010
import ResultViewer from './components/ResultViewer.vue'
1111
import SavedQueriesPanel from './components/SavedQueriesPanel.vue'
12-
import { backend, connect, connection } from './composables/rpc'
12+
import { backend, connect, connection, onDockActivation } from './composables/rpc'
1313
import { useSavedQueries } from './composables/saved'
1414
import { colorScheme } from './composables/scheme'
1515
import { useWorkbench, workbenchKey } from './composables/workbench'
@@ -34,6 +34,9 @@ onMounted(async () => {
3434
await Promise.all([wb.loadSources(), savedApi.refresh()])
3535
// Sources registered/unregistered after boot refresh the picker live.
3636
backend().onSourcesChanged(() => void wb.loadSources())
37+
// In a hub, another dock can deep-link straight to a source via dock
38+
// activation (`hub:docks:activate` with `params.sourceId`); focus it.
39+
void onDockActivation('devframes:plugin:data-inspector', id => wb.focusSource(id))
3740
// An empty query runs `$`: the workbench lands on the full source object.
3841
void wb.runNow()
3942
void wb.loadSkeleton()

plugins/data-inspector/src/spa/composables/rpc.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,48 @@ export function backend(): DataBackend {
6969
return backendRef.value
7070
}
7171

72+
// ── dock activation (deep-link focus, hub only) ──────────────────────
73+
74+
/** The hub mirrors the latest dock-activation intent into this shared-state slot. */
75+
const DOCKS_ACTIVE_STATE_KEY = 'devframe:docks:active'
76+
77+
/** The live client, kept for the shared-state subscription below (rpc mode). */
78+
let rpcClient: DevframeRpcClient | null = null
79+
80+
/**
81+
* Subscribe to the hub's dock-activation slot. When an activation targets this
82+
* dock (`dockId`) and carries a `sourceId`, invoke `apply` with it — the
83+
* deep-link path that lets another dock (e.g. a messages feed) jump the user
84+
* straight to a data source. Reads the slot once on subscribe (so a dock that
85+
* mounts *because* of the activation still converges) and on every update.
86+
* Inert outside a hub — static mode or no shared state simply never fires.
87+
*/
88+
export async function onDockActivation(dockId: string, apply: (sourceId: string) => void): Promise<void> {
89+
const client = rpcClient
90+
if (!client)
91+
return
92+
interface Activation { dockId?: string, params?: Record<string, unknown> }
93+
const handle = (v: { activation?: Activation | null } | undefined): void => {
94+
const activation = v?.activation
95+
if (!activation || activation.dockId !== dockId)
96+
return
97+
const sourceId = activation.params?.sourceId
98+
if (typeof sourceId === 'string')
99+
apply(sourceId)
100+
}
101+
try {
102+
const slot = await client.sharedState.get(DOCKS_ACTIVE_STATE_KEY, { initialValue: { activation: null } }) as {
103+
value: () => { activation?: Activation | null }
104+
on: (event: string, cb: (v: { activation?: Activation | null }) => void) => void
105+
}
106+
handle(slot.value())
107+
slot.on('updated', handle)
108+
}
109+
catch {
110+
// No hub / no shared state — deep-linking simply stays inert.
111+
}
112+
}
113+
72114
// ── rpc backend ──────────────────────────────────────────────────────
73115

74116
function createRpcBackend(client: DevframeRpcClient): DataBackend {
@@ -221,6 +263,7 @@ export async function connect(): Promise<void> {
221263
return
222264
}
223265
const client = await connectDevframe({ baseURL: './', authToken })
266+
rpcClient = client
224267
backendRef.value = createRpcBackend(client)
225268
applyStatus(client)
226269
client.events.on('connection:status', () => applyStatus(client))

plugins/data-inspector/src/spa/composables/workbench.ts

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,9 @@ function clampSeconds(value: number): number {
6464
return Math.min(MAX_AUTO_RERUN_SECONDS, Math.max(MIN_AUTO_RERUN_SECONDS, Math.round(value)))
6565
}
6666

67-
/** Read the shareable workbench state from the page URL. */
67+
/** Read the shareable workbench state from the page URL hash. */
6868
function readUrlState(): { sourceId: string, query: string, filters: FilterOptions, autoRun: boolean, autoRunSeconds: number } {
69-
const params = new URLSearchParams(location.search)
69+
const params = new URLSearchParams(location.hash.replace(/^#/, ''))
7070
const filters: FilterOptions = {}
7171
for (const key of FILTER_KEYS) {
7272
if (params.get(key) === '1')
@@ -120,6 +120,11 @@ export function useWorkbench() {
120120
const autoRunSeconds = ref(initial.autoRunSeconds)
121121

122122
// ── URL persistence: source, query, and filters stay shareable ──────
123+
// State lives in the hash (`#source=…&query=…`) so a copied link carries the
124+
// whole workbench without touching the query string the server handshake
125+
// (`?devframe_auth_token=`) rides on. `replaceState` keeps every keystroke
126+
// out of the history stack; the write is guarded so a no-op change (e.g. the
127+
// debounced write after a `hashchange` re-apply) never rewrites the URL.
123128
let urlTimer: ReturnType<typeof setTimeout> | undefined
124129
function syncUrl(): void {
125130
clearTimeout(urlTimer)
@@ -137,11 +142,31 @@ export function useWorkbench() {
137142
params.set('autorun', '1')
138143
params.set('autorunSecs', String(autoRunSeconds.value))
139144
}
140-
const search = params.toString()
141-
history.replaceState(null, '', search ? `?${search}` : location.pathname)
145+
const hash = params.toString()
146+
if (location.hash.replace(/^#/, '') === hash)
147+
return
148+
history.replaceState(history.state, '', hash ? `#${hash}` : location.pathname + location.search)
142149
}, URL_SYNC_DEBOUNCE)
143150
}
144151

152+
/**
153+
* Re-apply the full workbench state from the URL hash — the live reaction to
154+
* back/forward and manual address-bar edits (`hashchange`). Source is set
155+
* first so its draft-restore runs before the shared query overwrites it; an
156+
* unknown source id is left for `loadSources` to pick up. `replaceState`
157+
* writes never fire `hashchange`, so this can't loop with `syncUrl`.
158+
*/
159+
function applyUrlState(): void {
160+
const next = readUrlState()
161+
if (next.sourceId && next.sourceId !== sourceId.value && sources.value.some(s => s.id === next.sourceId))
162+
sourceId.value = next.sourceId
163+
query.value = next.query
164+
for (const key of FILTER_KEYS)
165+
settings[key] = next.filters[key] ?? false
166+
autoRun.value = next.autoRun
167+
autoRunSeconds.value = next.autoRunSeconds
168+
}
169+
145170
const syntax = ref<SyntaxState>({ kind: 'ok' })
146171
const running = ref(false)
147172
const serverError = ref<string | null>(null)
@@ -160,8 +185,16 @@ export function useWorkbench() {
160185

161186
const activeSource = computed(() => sources.value.find(s => s.id === sourceId.value))
162187

188+
// A dock-activation focus (`focusSource`) that names a source not yet
189+
// registered waits here until `loadSources` sees it arrive — then fires once.
190+
let pendingFocusId: string | null = null
191+
163192
async function loadSources(): Promise<void> {
164193
sources.value = await backend().sources()
194+
if (pendingFocusId && sources.value.some(s => s.id === pendingFocusId)) {
195+
sourceId.value = pendingFocusId
196+
pendingFocusId = null
197+
}
165198
if (!sourceId.value || !sources.value.some(s => s.id === sourceId.value))
166199
sourceId.value = sources.value[0]?.id ?? ''
167200
// A query arriving via the URL becomes the draft for its source, so the
@@ -170,6 +203,21 @@ export function useWorkbench() {
170203
saveDraft()
171204
}
172205

206+
/**
207+
* Select a source by id — the deep-link target of the hub's dock activation
208+
* (`params.sourceId`). If the source isn't registered yet, remember it and
209+
* converge the moment it appears (one-shot), mirroring the terminals dock.
210+
*/
211+
function focusSource(id: string): void {
212+
if (sources.value.some(s => s.id === id)) {
213+
sourceId.value = id
214+
pendingFocusId = null
215+
}
216+
else {
217+
pendingFocusId = id
218+
}
219+
}
220+
173221
// ── auto-run with syntax gate + stale-drop ─────────────────────────
174222
let runSeq = 0
175223
let runTimer: ReturnType<typeof setTimeout> | undefined
@@ -384,6 +432,10 @@ export function useWorkbench() {
384432
if (autoRun.value)
385433
restartAutoRerun()
386434

435+
// Live reaction: back/forward and manual hash edits re-apply the full state.
436+
if (typeof window !== 'undefined')
437+
window.addEventListener('hashchange', applyUrlState)
438+
387439
// ── query composition helpers ──────────────────────────────────────
388440
/** Set the query to a single top-level key (from the data-shape panel). */
389441
function queryProp(key: string): void {
@@ -427,6 +479,7 @@ export function useWorkbench() {
427479
skeletonLoading,
428480
loadSources,
429481
loadSkeleton,
482+
focusSource,
430483
expandNode,
431484
runNow,
432485
requestSuggestions,

0 commit comments

Comments
 (0)