Skip to content
Closed
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
17 changes: 17 additions & 0 deletions docs/guide/when-clauses.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,23 @@ ctx.docks.register({

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

### Dynamic `when` on registered/mounted docks

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:

```ts
ctx.docks.register({
id: 'my-devtool:sessions',
title: 'Sessions',
type: 'action',
icon: 'ph:cursor-duotone',
when: () => (sessions.size === 0 ? 'false' : undefined),
action: { importFrom: 'my-devtool/sessions' },
})
```

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.

## Expression syntax

### Operators
Expand Down
2 changes: 1 addition & 1 deletion packages/hub/src/define.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function defineDockEntry<
const T extends DevframeDockUserEntry,
const W extends string = '',
>(
entry: Omit<T, 'when'> & { when?: WhenExpression<WhenContext, W> },
entry: Omit<T, 'when'> & { when?: WhenExpression<WhenContext, W> | boolean | (() => string | boolean | undefined) },
): T {
return entry as unknown as T
}
Expand Down
78 changes: 78 additions & 0 deletions packages/hub/src/node/__tests__/host-docks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,3 +238,81 @@ describe('devframeDockHost built-in gating', () => {
expect(host.values({ includeBuiltin: false })).toEqual([])
})
})

describe('devframeDockHost dynamic `when`', () => {
it('resolves a function `when` on every values() call, reflecting the current return value', () => {
const host = new DevframeDocksHost(createContext())
let hidden = false

host.register({
type: 'iframe',
id: 'app:overview',
title: 'Overview',
icon: 'ph:gauge-duotone',
url: '/__app/',
when: () => (hidden ? 'false' : undefined),
})

expect(host.values({ includeBuiltin: false })[0].when).toBeUndefined()

hidden = true
expect(host.values({ includeBuiltin: false })[0].when).toBe('false')

hidden = false
expect(host.values({ includeBuiltin: false })[0].when).toBeUndefined()
})

it('resolves boolean `when`: false -> \'false\', true -> undefined', () => {
const host = new DevframeDocksHost(createContext())
host.register({
type: 'iframe',
id: 'app:hidden',
title: 'Hidden',
icon: 'ph:gauge-duotone',
url: '/__hidden/',
when: false,
})
host.register({
type: 'iframe',
id: 'app:shown',
title: 'Shown',
icon: 'ph:gauge-duotone',
url: '/__shown/',
when: true,
})

const entries = host.values({ includeBuiltin: false })
expect(entries.find(e => e.id === 'app:hidden')!.when).toBe('false')
expect(entries.find(e => e.id === 'app:shown')!.when).toBeUndefined()
})

it('passes a string `when` through unchanged', () => {
const host = new DevframeDocksHost(createContext())
host.register({
type: 'iframe',
id: 'app:embedded-only',
title: 'Embedded Only',
icon: 'ph:gauge-duotone',
url: '/__embedded-only/',
when: 'clientType == embedded',
})

expect(host.values({ includeBuiltin: false })[0].when).toBe('clientType == embedded')
})

it('does not mutate the stored entry when resolving a function `when`', () => {
const host = new DevframeDocksHost(createContext())
const whenFn = () => 'false' as const
host.register({
type: 'iframe',
id: 'app:overview',
title: 'Overview',
icon: 'ph:gauge-duotone',
url: '/__app/',
when: whenFn,
})

host.values({ includeBuiltin: false })
expect(host.views.get('app:overview')!.when).toBe(whenFn)
})
})
14 changes: 14 additions & 0 deletions packages/hub/src/node/__tests__/mount-devframe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,20 @@ describe('mountDevframe', () => {
expect(ctx.docks.views.size).toBe(1)
})

it('flows a function `when` through the `options.dock` spread and re-resolves it per values() call', async () => {
const ctx = createContext()
let hidden = false

await mountDevframe(ctx, makeDevframe(), {
dock: { when: () => (hidden ? 'false' : undefined) },
})

expect(ctx.docks.values({ includeBuiltin: false })[0].when).toBeUndefined()

hidden = true
expect(ctx.docks.values({ includeBuiltin: false })[0].when).toBe('false')
})

it('lets instances coexist under disambiguated ids when "duplicate"', async () => {
const ctx = createContext()
const setup = vi.fn()
Expand Down
22 changes: 16 additions & 6 deletions packages/hub/src/node/host-docks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { createEventEmitter } from 'devframe/utils/events'
import { join } from 'pathe'
import { DEFAULT_STATE_USER_SETTINGS } from '../constants'
import { diagnostics } from './diagnostics'
import { resolveWhen } from './when'

interface RemoteDockRecord {
token: string
Expand Down Expand Up @@ -162,13 +163,22 @@ export class DevframeDocksHost implements DevframeDocksHostType {
]
}

/**
* Turn a registered entry into its serializable wire form: resolve
* `when` down to `string | undefined` (see {@link resolveWhen}) and, for
* remote iframes, inject the connection descriptor into the URL. Always
* returns a shallow copy — the stored entry (and any `when` function on
* it) is left untouched for the next call.
*/
private projectView(view: DevframeDockUserEntry): DevframeDockUserEntry {
if (view.type !== 'iframe' || !view.remote)
return view
const record = this.remoteDocks.get(view.id)
const resolved: DevframeDockUserEntry = { ...view, when: resolveWhen(view.when) }

if (resolved.type !== 'iframe' || !resolved.remote)
return resolved
const record = this.remoteDocks.get(resolved.id)
const endpoint = getInternalContext(this.context as DevframeNodeContext).wsEndpoint
if (!record || !endpoint)
return view
return resolved

const payload: RemoteConnectionInfo = {
v: 1,
Expand All @@ -178,8 +188,8 @@ export class DevframeDocksHost implements DevframeDocksHostType {
origin: this.resolveDevServerOrigin(),
}
return {
...view,
url: buildRemoteUrl(view.url, payload, record.options.transport),
...resolved,
url: buildRemoteUrl(resolved.url, payload, record.options.transport),
} satisfies DevframeViewIframe
}

Expand Down
1 change: 1 addition & 0 deletions packages/hub/src/node/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export * from './host-terminals'
export * from './mount-devframe'
export * from './rpc-builtins'
export * from './utils'
export * from './when'
5 changes: 5 additions & 0 deletions packages/hub/src/node/mount-devframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ export interface MountDevframeOptions {
* customize the entry's `category`, override the icon, hide it via
* `when`, etc. Cannot change `id`, `type`, or `url` — those are
* derived from the devframe definition.
*
* `when` may be a function (`() => string | boolean | undefined`),
* re-invoked every time the dock is serialized — the mounted-dock
* equivalent of the built-in docks' `when` getters, since a getter here
* would only ever run once through this spread.
*/
dock?: Partial<Omit<DevframeViewIframe, 'id' | 'type' | 'url'>>
}
Expand Down
20 changes: 20 additions & 0 deletions packages/hub/src/node/when.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { DevframeWhen } from '../types/docks'

/**
* Resolve a dock entry's authored {@link DevframeWhen} down to the wire
* contract (`string | undefined`) that clients evaluate with `whenexpr`.
*
* Called once per serialization (`DevframeDocksHost.values()` /
* `projectView`) so a function `when` is re-invoked on every pass — the
* server-side equivalent of the built-ins' `get when()` getters, but one
* that survives being copied by value (e.g. `mountDevframe`'s
* `...options.dock` spread).
*/
export function resolveWhen(when: DevframeWhen | undefined): string | undefined {
const value = typeof when === 'function' ? when() : when
if (value === false)
return 'false'
if (value === true || value == null)
return undefined
return value
}
35 changes: 30 additions & 5 deletions packages/hub/src/types/docks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,26 @@ export type DevframeDockEntryCategory

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

/**
* A dock entry's `when` value, as authored.
*
* - `string` — a `whenexpr` expression, evaluated client-side against a
* `WhenContext` (unchanged from before).
* - `boolean` — a static shortcut: `false` unconditionally hides the entry,
* `true` unconditionally shows it.
* - `() => string | boolean | undefined` — a live clause, invoked server-side
* every time the dock's shared state is serialized. Lets a host give a
* registered/mounted dock the same dynamic visibility the built-in
* `~terminals`/`~messages` entries have, without relying on a getter that
* would be evaluated only once by `mountDevframe`'s `...options.dock` spread.
*
* Whichever form is authored, it is resolved down to the wire contract
* (`string | undefined`) during serialization — see
* {@link import('../node/when').resolveWhen}. Clients only ever see a
* `string | undefined`, evaluated the same way as always.
*/
export type DevframeWhen = string | boolean | (() => string | boolean | undefined)

export interface DevframeDockEntryBase {
id: string
title: string
Expand All @@ -70,16 +90,21 @@ export interface DevframeDockEntryBase {
*/
category?: DevframeDockEntryCategory
/**
* Conditional visibility expression.
* When set, the dock entry is only visible when the expression evaluates to true.
* Uses the same syntax as command `when` clauses.
* Conditional visibility.
* When set, the dock entry is only visible when it resolves to true.
* A string uses the same `whenexpr` syntax as command `when` clauses and
* is still evaluated client-side. A boolean or a function is a
* server-side authoring convenience, resolved to a `string | undefined`
* once per serialization — see {@link DevframeWhen}.
*
* Set to `'false'` to unconditionally hide the entry.
* Set to `'false'` (or a function returning `false`) to unconditionally
* hide the entry.
*
* @example 'clientType == embedded'
* @example () => sessions.size === 0 ? 'false' : undefined
* @see {@link import('devframe/utils/when').evaluateWhen}
*/
when?: string
when?: DevframeWhen
/**
* Badge text to display on the dock icon (e.g., unread count)
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export declare function defineCommand<const W extends string = ''>(_: Omit<Devfr
when?: WhenExpression<WhenContext, W>;
}): DevframeServerCommandInput;
export declare function defineDockEntry<const T extends DevframeDockUserEntry, const W extends string = ''>(_: Omit<T, 'when'> & {
when?: WhenExpression<WhenContext, W>;
when?: WhenExpression<WhenContext, W> | boolean | (() => string | boolean | undefined);
}): T;
export declare function defineJsonRenderSpec(_: JsonRenderSpec): JsonRenderSpec;
// #endregion
Expand Down Expand Up @@ -77,6 +77,7 @@ export { DevframeViewIframe }
export { DevframeViewJsonRender }
export { DevframeViewLauncher }
export { DevframeViewLauncherStatus }
export { DevframeWhen }
export { EntriesToObject }
export { EventEmitter }
export { EventsMap }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export declare class DevframeTerminalsHost implements DevframeTerminalsHost$1 {
// #region Functions
export declare function createSimpleClientScript(_: string | ((_: any) => void)): ClientScriptEntry;
export declare function mountDevframe(_: DevframeHubContext, _: DevframeDefinition, _?: MountDevframeOptions): Promise<void>;
export declare function resolveWhen(_: DevframeWhen | undefined): string | undefined;
// #endregion

// #region Variables
Expand Down
1 change: 1 addition & 0 deletions tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export class DevframeTerminalsHost {
export async function createHubContext(_) {}
export function createSimpleClientScript(_) {}
export async function mountDevframe(_, _, _) {}
export function resolveWhen(_) {}
// #endregion

// #region Variables
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export { DevframeViewIframe }
export { DevframeViewJsonRender }
export { DevframeViewLauncher }
export { DevframeViewLauncherStatus }
export { DevframeWhen }
export { EntriesToObject }
export { EventEmitter }
export { EventsMap }
Expand Down
Loading