Skip to content

Commit aa7d2b6

Browse files
committed
feat(hub): support functional/boolean when on dock entries
Widen dock-entry `when` to `string | boolean | (() => string | boolean | undefined)`, resolved to the existing `string | undefined` wire contract once per `DevframeDocksHost.values()`/`projectView` serialization pass. This lets a host give a dock registered via `docks.register(...)` or mounted via `mountDevframe(ctx, def, { dock })` the same dynamic visibility the built-in `~terminals`/`~messages` entries get from an object-literal getter — a function survives `mountDevframe`'s `...options.dock` spread by reference, where a getter would only run once. Clients and `whenexpr` are unaffected; the resolved value is still a plain string expression or `undefined`.
1 parent 36cde4e commit aa7d2b6

13 files changed

Lines changed: 187 additions & 13 deletions

File tree

docs/guide/when-clauses.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,23 @@ ctx.docks.register({
4040

4141
`when: 'false'` hides an entry unconditionally.
4242

43+
### Dynamic `when` on registered/mounted docks
44+
45+
A dock entry's `when` also accepts a boolean or a function, resolved to the string form above once per serialization (`ctx.docks.values()`) — a server-side authoring convenience for docks whose visibility depends on live state:
46+
47+
```ts
48+
ctx.docks.register({
49+
id: 'my-devtool:sessions',
50+
title: 'Sessions',
51+
type: 'action',
52+
icon: 'ph:cursor-duotone',
53+
when: () => (sessions.size === 0 ? 'false' : undefined),
54+
action: { importFrom: 'my-devtool/sessions' },
55+
})
56+
```
57+
58+
The function is re-invoked on every serialization pass, so it reflects the current state each time a client reads `devframe:docks` — the same pattern the hub's built-in `~terminals` dock uses to hide itself while no terminal session is open. `false` resolves to `'false'`; `true` resolves to `undefined` (unconditionally visible); a returned string is passed through unchanged and still evaluated client-side. This is what makes a function `when` useful specifically for a dock registered via `mountDevframe`'s `dock` option — that object is spread once, so a getter would only run once, but a function value survives the spread by reference.
59+
4360
## Expression syntax
4461

4562
### Operators

packages/hub/src/define.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export function defineDockEntry<
1717
const T extends DevframeDockUserEntry,
1818
const W extends string = '',
1919
>(
20-
entry: Omit<T, 'when'> & { when?: WhenExpression<WhenContext, W> },
20+
entry: Omit<T, 'when'> & { when?: WhenExpression<WhenContext, W> | boolean | (() => string | boolean | undefined) },
2121
): T {
2222
return entry as unknown as T
2323
}

packages/hub/src/node/__tests__/host-docks.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,3 +238,81 @@ describe('devframeDockHost built-in gating', () => {
238238
expect(host.values({ includeBuiltin: false })).toEqual([])
239239
})
240240
})
241+
242+
describe('devframeDockHost dynamic `when`', () => {
243+
it('resolves a function `when` on every values() call, reflecting the current return value', () => {
244+
const host = new DevframeDocksHost(createContext())
245+
let hidden = false
246+
247+
host.register({
248+
type: 'iframe',
249+
id: 'app:overview',
250+
title: 'Overview',
251+
icon: 'ph:gauge-duotone',
252+
url: '/__app/',
253+
when: () => (hidden ? 'false' : undefined),
254+
})
255+
256+
expect(host.values({ includeBuiltin: false })[0].when).toBeUndefined()
257+
258+
hidden = true
259+
expect(host.values({ includeBuiltin: false })[0].when).toBe('false')
260+
261+
hidden = false
262+
expect(host.values({ includeBuiltin: false })[0].when).toBeUndefined()
263+
})
264+
265+
it('resolves boolean `when`: false -> \'false\', true -> undefined', () => {
266+
const host = new DevframeDocksHost(createContext())
267+
host.register({
268+
type: 'iframe',
269+
id: 'app:hidden',
270+
title: 'Hidden',
271+
icon: 'ph:gauge-duotone',
272+
url: '/__hidden/',
273+
when: false,
274+
})
275+
host.register({
276+
type: 'iframe',
277+
id: 'app:shown',
278+
title: 'Shown',
279+
icon: 'ph:gauge-duotone',
280+
url: '/__shown/',
281+
when: true,
282+
})
283+
284+
const entries = host.values({ includeBuiltin: false })
285+
expect(entries.find(e => e.id === 'app:hidden')!.when).toBe('false')
286+
expect(entries.find(e => e.id === 'app:shown')!.when).toBeUndefined()
287+
})
288+
289+
it('passes a string `when` through unchanged', () => {
290+
const host = new DevframeDocksHost(createContext())
291+
host.register({
292+
type: 'iframe',
293+
id: 'app:embedded-only',
294+
title: 'Embedded Only',
295+
icon: 'ph:gauge-duotone',
296+
url: '/__embedded-only/',
297+
when: 'clientType == embedded',
298+
})
299+
300+
expect(host.values({ includeBuiltin: false })[0].when).toBe('clientType == embedded')
301+
})
302+
303+
it('does not mutate the stored entry when resolving a function `when`', () => {
304+
const host = new DevframeDocksHost(createContext())
305+
const whenFn = () => 'false' as const
306+
host.register({
307+
type: 'iframe',
308+
id: 'app:overview',
309+
title: 'Overview',
310+
icon: 'ph:gauge-duotone',
311+
url: '/__app/',
312+
when: whenFn,
313+
})
314+
315+
host.values({ includeBuiltin: false })
316+
expect(host.views.get('app:overview')!.when).toBe(whenFn)
317+
})
318+
})

packages/hub/src/node/__tests__/mount-devframe.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,20 @@ describe('mountDevframe', () => {
9393
expect(ctx.docks.views.size).toBe(1)
9494
})
9595

96+
it('flows a function `when` through the `options.dock` spread and re-resolves it per values() call', async () => {
97+
const ctx = createContext()
98+
let hidden = false
99+
100+
await mountDevframe(ctx, makeDevframe(), {
101+
dock: { when: () => (hidden ? 'false' : undefined) },
102+
})
103+
104+
expect(ctx.docks.values({ includeBuiltin: false })[0].when).toBeUndefined()
105+
106+
hidden = true
107+
expect(ctx.docks.values({ includeBuiltin: false })[0].when).toBe('false')
108+
})
109+
96110
it('lets instances coexist under disambiguated ids when "duplicate"', async () => {
97111
const ctx = createContext()
98112
const setup = vi.fn()

packages/hub/src/node/host-docks.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { createEventEmitter } from 'devframe/utils/events'
1919
import { join } from 'pathe'
2020
import { DEFAULT_STATE_USER_SETTINGS } from '../constants'
2121
import { diagnostics } from './diagnostics'
22+
import { resolveWhen } from './when'
2223

2324
interface RemoteDockRecord {
2425
token: string
@@ -162,13 +163,22 @@ export class DevframeDocksHost implements DevframeDocksHostType {
162163
]
163164
}
164165

166+
/**
167+
* Turn a registered entry into its serializable wire form: resolve
168+
* `when` down to `string | undefined` (see {@link resolveWhen}) and, for
169+
* remote iframes, inject the connection descriptor into the URL. Always
170+
* returns a shallow copy — the stored entry (and any `when` function on
171+
* it) is left untouched for the next call.
172+
*/
165173
private projectView(view: DevframeDockUserEntry): DevframeDockUserEntry {
166-
if (view.type !== 'iframe' || !view.remote)
167-
return view
168-
const record = this.remoteDocks.get(view.id)
174+
const resolved: DevframeDockUserEntry = { ...view, when: resolveWhen(view.when) }
175+
176+
if (resolved.type !== 'iframe' || !resolved.remote)
177+
return resolved
178+
const record = this.remoteDocks.get(resolved.id)
169179
const endpoint = getInternalContext(this.context as DevframeNodeContext).wsEndpoint
170180
if (!record || !endpoint)
171-
return view
181+
return resolved
172182

173183
const payload: RemoteConnectionInfo = {
174184
v: 1,
@@ -178,8 +188,8 @@ export class DevframeDocksHost implements DevframeDocksHostType {
178188
origin: this.resolveDevServerOrigin(),
179189
}
180190
return {
181-
...view,
182-
url: buildRemoteUrl(view.url, payload, record.options.transport),
191+
...resolved,
192+
url: buildRemoteUrl(resolved.url, payload, record.options.transport),
183193
} satisfies DevframeViewIframe
184194
}
185195

packages/hub/src/node/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ export * from './host-terminals'
66
export * from './mount-devframe'
77
export * from './rpc-builtins'
88
export * from './utils'
9+
export * from './when'

packages/hub/src/node/mount-devframe.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ export interface MountDevframeOptions {
1515
* customize the entry's `category`, override the icon, hide it via
1616
* `when`, etc. Cannot change `id`, `type`, or `url` — those are
1717
* derived from the devframe definition.
18+
*
19+
* `when` may be a function (`() => string | boolean | undefined`),
20+
* re-invoked every time the dock is serialized — the mounted-dock
21+
* equivalent of the built-in docks' `when` getters, since a getter here
22+
* would only ever run once through this spread.
1823
*/
1924
dock?: Partial<Omit<DevframeViewIframe, 'id' | 'type' | 'url'>>
2025
}

packages/hub/src/node/when.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { DevframeWhen } from '../types/docks'
2+
3+
/**
4+
* Resolve a dock entry's authored {@link DevframeWhen} down to the wire
5+
* contract (`string | undefined`) that clients evaluate with `whenexpr`.
6+
*
7+
* Called once per serialization (`DevframeDocksHost.values()` /
8+
* `projectView`) so a function `when` is re-invoked on every pass — the
9+
* server-side equivalent of the built-ins' `get when()` getters, but one
10+
* that survives being copied by value (e.g. `mountDevframe`'s
11+
* `...options.dock` spread).
12+
*/
13+
export function resolveWhen(when: DevframeWhen | undefined): string | undefined {
14+
const value = typeof when === 'function' ? when() : when
15+
if (value === false)
16+
return 'false'
17+
if (value === true || value == null)
18+
return undefined
19+
return value
20+
}

packages/hub/src/types/docks.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,26 @@ export type DevframeDockEntryCategory
5454

5555
export type DevframeDockEntryIcon = string | { light: string, dark: string }
5656

57+
/**
58+
* A dock entry's `when` value, as authored.
59+
*
60+
* - `string` — a `whenexpr` expression, evaluated client-side against a
61+
* `WhenContext` (unchanged from before).
62+
* - `boolean` — a static shortcut: `false` unconditionally hides the entry,
63+
* `true` unconditionally shows it.
64+
* - `() => string | boolean | undefined` — a live clause, invoked server-side
65+
* every time the dock's shared state is serialized. Lets a host give a
66+
* registered/mounted dock the same dynamic visibility the built-in
67+
* `~terminals`/`~messages` entries have, without relying on a getter that
68+
* would be evaluated only once by `mountDevframe`'s `...options.dock` spread.
69+
*
70+
* Whichever form is authored, it is resolved down to the wire contract
71+
* (`string | undefined`) during serialization — see
72+
* {@link import('../node/when').resolveWhen}. Clients only ever see a
73+
* `string | undefined`, evaluated the same way as always.
74+
*/
75+
export type DevframeWhen = string | boolean | (() => string | boolean | undefined)
76+
5777
export interface DevframeDockEntryBase {
5878
id: string
5979
title: string
@@ -70,16 +90,21 @@ export interface DevframeDockEntryBase {
7090
*/
7191
category?: DevframeDockEntryCategory
7292
/**
73-
* Conditional visibility expression.
74-
* When set, the dock entry is only visible when the expression evaluates to true.
75-
* Uses the same syntax as command `when` clauses.
93+
* Conditional visibility.
94+
* When set, the dock entry is only visible when it resolves to true.
95+
* A string uses the same `whenexpr` syntax as command `when` clauses and
96+
* is still evaluated client-side. A boolean or a function is a
97+
* server-side authoring convenience, resolved to a `string | undefined`
98+
* once per serialization — see {@link DevframeWhen}.
7699
*
77-
* Set to `'false'` to unconditionally hide the entry.
100+
* Set to `'false'` (or a function returning `false`) to unconditionally
101+
* hide the entry.
78102
*
79103
* @example 'clientType == embedded'
104+
* @example () => sessions.size === 0 ? 'false' : undefined
80105
* @see {@link import('devframe/utils/when').evaluateWhen}
81106
*/
82-
when?: string
107+
when?: DevframeWhen
83108
/**
84109
* Badge text to display on the dock icon (e.g., unread count)
85110
*/

tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export declare function defineCommand<const W extends string = ''>(_: Omit<Devfr
66
when?: WhenExpression<WhenContext, W>;
77
}): DevframeServerCommandInput;
88
export declare function defineDockEntry<const T extends DevframeDockUserEntry, const W extends string = ''>(_: Omit<T, 'when'> & {
9-
when?: WhenExpression<WhenContext, W>;
9+
when?: WhenExpression<WhenContext, W> | boolean | (() => string | boolean | undefined);
1010
}): T;
1111
export declare function defineJsonRenderSpec(_: JsonRenderSpec): JsonRenderSpec;
1212
// #endregion
@@ -77,6 +77,7 @@ export { DevframeViewIframe }
7777
export { DevframeViewJsonRender }
7878
export { DevframeViewLauncher }
7979
export { DevframeViewLauncherStatus }
80+
export { DevframeWhen }
8081
export { EntriesToObject }
8182
export { EventEmitter }
8283
export { EventsMap }

0 commit comments

Comments
 (0)