diff --git a/docs/guide/when-clauses.md b/docs/guide/when-clauses.md index 6bf26c0..6a0c40f 100644 --- a/docs/guide/when-clauses.md +++ b/docs/guide/when-clauses.md @@ -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 diff --git a/packages/hub/src/define.ts b/packages/hub/src/define.ts index e69b87b..6ff2ee1 100644 --- a/packages/hub/src/define.ts +++ b/packages/hub/src/define.ts @@ -17,7 +17,7 @@ export function defineDockEntry< const T extends DevframeDockUserEntry, const W extends string = '', >( - entry: Omit & { when?: WhenExpression }, + entry: Omit & { when?: WhenExpression | boolean | (() => string | boolean | undefined) }, ): T { return entry as unknown as T } diff --git a/packages/hub/src/node/__tests__/host-docks.test.ts b/packages/hub/src/node/__tests__/host-docks.test.ts index ede10a6..692b1f5 100644 --- a/packages/hub/src/node/__tests__/host-docks.test.ts +++ b/packages/hub/src/node/__tests__/host-docks.test.ts @@ -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) + }) +}) diff --git a/packages/hub/src/node/__tests__/mount-devframe.test.ts b/packages/hub/src/node/__tests__/mount-devframe.test.ts index 9da3924..560ac5d 100644 --- a/packages/hub/src/node/__tests__/mount-devframe.test.ts +++ b/packages/hub/src/node/__tests__/mount-devframe.test.ts @@ -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() diff --git a/packages/hub/src/node/host-docks.ts b/packages/hub/src/node/host-docks.ts index 6a1a391..247b036 100644 --- a/packages/hub/src/node/host-docks.ts +++ b/packages/hub/src/node/host-docks.ts @@ -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 @@ -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, @@ -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 } diff --git a/packages/hub/src/node/index.ts b/packages/hub/src/node/index.ts index c5160de..faf3dc6 100644 --- a/packages/hub/src/node/index.ts +++ b/packages/hub/src/node/index.ts @@ -6,3 +6,4 @@ export * from './host-terminals' export * from './mount-devframe' export * from './rpc-builtins' export * from './utils' +export * from './when' diff --git a/packages/hub/src/node/mount-devframe.ts b/packages/hub/src/node/mount-devframe.ts index 935ebd2..0e83ab3 100644 --- a/packages/hub/src/node/mount-devframe.ts +++ b/packages/hub/src/node/mount-devframe.ts @@ -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> } diff --git a/packages/hub/src/node/when.ts b/packages/hub/src/node/when.ts new file mode 100644 index 0000000..4cde10a --- /dev/null +++ b/packages/hub/src/node/when.ts @@ -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 +} diff --git a/packages/hub/src/types/docks.ts b/packages/hub/src/types/docks.ts index 92d8714..83e5401 100644 --- a/packages/hub/src/types/docks.ts +++ b/packages/hub/src/types/docks.ts @@ -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 @@ -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) */ diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts index 6230f5d..68d57c4 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts @@ -6,7 +6,7 @@ export declare function defineCommand(_: Omit; }): DevframeServerCommandInput; export declare function defineDockEntry(_: Omit & { - when?: WhenExpression; + when?: WhenExpression | boolean | (() => string | boolean | undefined); }): T; export declare function defineJsonRenderSpec(_: JsonRenderSpec): JsonRenderSpec; // #endregion @@ -77,6 +77,7 @@ export { DevframeViewIframe } export { DevframeViewJsonRender } export { DevframeViewLauncher } export { DevframeViewLauncherStatus } +export { DevframeWhen } export { EntriesToObject } export { EventEmitter } export { EventsMap } diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts index 9af324f..d82c65d 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts @@ -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; +export declare function resolveWhen(_: DevframeWhen | undefined): string | undefined; // #endregion // #region Variables diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js index 5740f8f..b46e9a3 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js +++ b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js @@ -76,6 +76,7 @@ export class DevframeTerminalsHost { export async function createHubContext(_) {} export function createSimpleClientScript(_) {} export async function mountDevframe(_, _, _) {} +export function resolveWhen(_) {} // #endregion // #region Variables diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts index 752de5c..84066d5 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts @@ -63,6 +63,7 @@ export { DevframeViewIframe } export { DevframeViewJsonRender } export { DevframeViewLauncher } export { DevframeViewLauncherStatus } +export { DevframeWhen } export { EntriesToObject } export { EventEmitter } export { EventsMap }