1- import type { DevframeDockEntry } from '@devframes/hub/types'
1+ import type { DevframeDockEntry , DevframeViewLauncher } from '@devframes/hub/types'
22import { connectDevframe } from '@devframes/hub/client'
33import { createIframePanes } from 'iframe-pane'
44import { iconClass } from './icons'
@@ -7,13 +7,16 @@ import '@antfu/design/styles.css'
77
88const HUB_BASE = '/__hub/'
99
10- // Mirror of the host's `storybook-hub:ensure` return shape.
10+ // Mirror of the launch command's return shape (`storybook-hub:launch:<id>`,
11+ // dispatched over `hub:commands:execute`).
1112type EnsureResult
1213 = | { ok : true , kind : 'port' , port : number }
1314 | { ok : true , kind : 'path' , url : string }
1415 | { ok : false , error : string }
1516
1617type IframeDock = DevframeDockEntry & { type : 'iframe' , url : string }
18+ type LauncherDock = DevframeViewLauncher
19+ type Dock = IframeDock | LauncherDock
1720
1821/** Sidebar section order; anything else follows alphabetically. */
1922const CATEGORY_ORDER = [ 'Storybooks' , 'Plugins' ]
@@ -28,13 +31,14 @@ interface DockRuntime {
2831 error ?: string
2932}
3033
31- // Every opened dock's iframe is parked here for its whole lifetime — switching
34+ // Every launched dock's iframe is parked here for its whole lifetime — switching
3235// tabs only mounts/unmounts the pane over `#stage`, so background docks keep
3336// their state (Storybook's own routing, scroll, etc.) intact.
3437const panes = createIframePanes ( { container : stageEl } )
3538const runtimes = new Map < string , DockRuntime > ( )
36- let docks : IframeDock [ ] = [ ]
39+ let docks : Dock [ ] = [ ]
3740let selectedId : string | null = null
41+ let rpc : Awaited < ReturnType < typeof connectDevframe > >
3842
3943function setStatus ( text : string , kind ?: 'ready' | 'error' ) {
4044 const dot = kind === 'ready' ? 'bg-success' : kind === 'error' ? 'bg-error' : 'bg-neutral-400'
@@ -45,8 +49,8 @@ function isIframeDock(d: DevframeDockEntry): d is IframeDock {
4549 return d . type === 'iframe' && typeof ( d as { url ?: unknown } ) . url === 'string'
4650}
4751
48- function isStorybookDock ( id : string ) : boolean {
49- return id . startsWith ( 'sb-' )
52+ function isLauncherDock ( d : DevframeDockEntry ) : d is LauncherDock {
53+ return d . type === 'launcher'
5054}
5155
5256function runtimeFor ( id : string ) : DockRuntime {
@@ -66,14 +70,23 @@ function dockIcon(entry: DevframeDockEntry): string {
6670 return `<span class="grid h-5 w-5 shrink-0 place-items-center rounded bg-active text-[0.7rem] font-bold">${ initial } </span>`
6771}
6872
69- function overlay ( kind : 'spin' | 'error' | 'idle' , title : string , detail = '' ) {
70- const glyph = kind === 'spin'
71- ? '<span class="i-ph-circle-notch animate-spin text-3xl color-active"></span>'
72- : kind === 'error'
73- ? '<span class="i-ph-warning-duotone text-3xl text-error"></span>'
74- : '<span class="i-ph-books-duotone text-3xl op-fade"></span>'
73+ function overlay ( html : string ) {
7574 overlayEl . style . display = 'flex'
76- overlayEl . innerHTML = `<div class="flex flex-col items-center gap-3 text-center px6">${ glyph } <div class="text-sm font-medium">${ title } </div>${ detail ? `<div class="text-xs font-mono op-mute max-w-md break-words">${ detail } </div>` : '' } </div>`
75+ overlayEl . innerHTML = `<div class="flex flex-col items-center gap-4 text-center px6 max-w-md">${ html } </div>`
76+ }
77+
78+ /** The idle launcher tile: a start button that lazily boots the Storybook. */
79+ function launcherTile ( entry : LauncherDock ) : string {
80+ const l = entry . launcher
81+ const cls = iconClass ( l . icon ?? entry . icon )
82+ const glyph = cls ? `<span class="${ cls } text-4xl color-active"></span>` : '<span class="i-ph-books-duotone text-4xl op-fade"></span>'
83+ return `
84+ ${ glyph }
85+ <div class="text-base font-medium">${ l . title } </div>
86+ ${ l . description ? `<div class="text-sm color-muted">${ l . description } </div>` : '' }
87+ <button type="button" data-launch="${ entry . id } " class="btn-primary">
88+ <span class="i-ph-play-duotone"></span>${ l . buttonStart ?? 'Start' }
89+ </button>`
7790}
7891
7992function updateStage ( ) {
@@ -85,57 +98,90 @@ function updateStage() {
8598 }
8699
87100 if ( ! selectedId ) {
88- overlay ( 'idle' , ' No dock selected')
101+ overlay ( '<span class="i-ph-books-duotone text-3xl op-fade"></span><div class="text-sm font-medium"> No dock selected</div> ' )
89102 return
90103 }
91- const rt = runtimes . get ( selectedId )
92- const title = docks . find ( d => d . id === selectedId ) ?. title ?? selectedId
93- if ( ! rt || rt . status === 'starting' || ( rt . status !== 'error' && ! panes . has ( selectedId ) ) ) {
94- overlay ( 'spin' , isStorybookDock ( selectedId ) ? `Starting ${ title } Storybook…` : `Loading ${ title } …` )
104+
105+ const entry = docks . find ( d => d . id === selectedId )
106+ const rt = runtimeFor ( selectedId )
107+ const title = entry ?. title ?? selectedId
108+
109+ // A launched pane is mounted — hide the overlay and show the live iframe.
110+ if ( rt . status === 'ready' && panes . has ( selectedId ) ) {
111+ overlayEl . style . display = 'none'
95112 return
96113 }
114+
97115 if ( rt . status === 'error' ) {
98- overlay ( 'error' , `Failed to start ${ title } ` , rt . error )
116+ overlay ( `
117+ <span class="i-ph-warning-duotone text-4xl text-error"></span>
118+ <div class="text-sm font-medium">Failed to start ${ title } </div>
119+ ${ rt . error ? `<div class="text-xs font-mono op-mute break-words">${ rt . error } </div>` : '' }
120+ ${ entry && isLauncherDock ( entry ) ? `<button type="button" data-launch="${ entry . id } " class="btn-action"><span class="i-ph-arrow-clockwise-duotone"></span>Retry</button>` : '' } ` )
121+ return
122+ }
123+
124+ // A launcher: idle shows its start tile; starting mirrors the live `digest`
125+ // (the tail of the `storybook dev` output the host streams onto the tile).
126+ if ( entry && isLauncherDock ( entry ) ) {
127+ if ( rt . status === 'starting' ) {
128+ const digest = entry . launcher . digest
129+ overlay ( `
130+ <span class="i-ph-circle-notch animate-spin text-3xl color-active"></span>
131+ <div class="text-sm font-medium">Starting ${ title } …</div>
132+ ${ digest ? `<div class="text-xs font-mono op-mute break-words">${ digest } </div>` : '' }
133+ <button type="button" data-terminals class="btn-action text-xs"><span class="i-ph-terminal-window-duotone"></span>Watch output in Terminals</button>` )
134+ }
135+ else {
136+ overlay ( launcherTile ( entry ) )
137+ }
99138 return
100139 }
101- overlayEl . style . display = 'none'
102- }
103140
104- async function ensureUrl ( rpc : Awaited < ReturnType < typeof connectDevframe > > , entry : IframeDock ) : Promise < string > {
105- // Live plugin docks already carry a hub-served URL; only Storybook docks are
106- // resolved on demand (spawned in dev, static in build).
107- if ( ! isStorybookDock ( entry . id ) )
108- return entry . url
141+ // A plain iframe dock (the live terminals plugin) is still booting.
142+ overlay ( `<span class="i-ph-circle-notch animate-spin text-3xl color-active"></span><div class="text-sm font-medium">Loading ${ title } …</div>` )
143+ }
109144
110- const result = await rpc . call ( 'storybook-hub:ensure' as any , { id : entry . id . slice ( 3 ) } ) as EnsureResult
111- if ( ! result . ok )
112- throw new Error ( result . error )
145+ /** Resolve the URL an EnsureResult points at (spawned dev port, or static path). */
146+ function resultUrl ( result : Extract < EnsureResult , { ok : true } > ) : string {
113147 return result . kind === 'path'
114148 ? result . url
115149 : `${ location . protocol } //${ location . hostname } :${ result . port } /`
116150}
117151
118- function initDock ( rpc : Awaited < ReturnType < typeof connectDevframe > > , entry : IframeDock ) {
152+ function embedIframe ( entry : Dock , url : string ) {
153+ const rt = runtimeFor ( entry . id )
154+ panes . ensure ( entry . id , {
155+ src : url ,
156+ attrs : { title : entry . title , allow : 'clipboard-read; clipboard-write' } ,
157+ style : { border : '0' } ,
158+ onCreated : ( iframe ) => {
159+ iframe . addEventListener ( 'load' , ( ) => {
160+ rt . status = 'ready'
161+ updateStage ( )
162+ } )
163+ } ,
164+ } )
165+ updateStage ( )
166+ }
167+
168+ /** Launch a Storybook: dispatch its bound command, then iframe the result. */
169+ function launch ( entry : LauncherDock ) {
119170 const rt = runtimeFor ( entry . id )
120- if ( rt . status !== 'idle' )
121- return
122171 rt . status = 'starting'
172+ rt . error = undefined
123173 updateStage ( )
124174
125- ensureUrl ( rpc , entry )
126- . then ( ( url ) => {
127- panes . ensure ( entry . id , {
128- src : url ,
129- attrs : { title : entry . title , allow : 'clipboard-read; clipboard-write' } ,
130- style : { border : '0' } ,
131- onCreated : ( iframe ) => {
132- iframe . addEventListener ( 'load' , ( ) => {
133- rt . status = 'ready'
134- updateStage ( )
135- } )
136- } ,
137- } )
138- updateStage ( )
175+ const command = entry . launcher . command
176+ const dispatch = command
177+ ? rpc . call ( 'hub:commands:execute' as any , command ) as Promise < EnsureResult >
178+ : Promise . reject ( new Error ( 'Launcher has no bound command' ) )
179+
180+ dispatch
181+ . then ( ( result ) => {
182+ if ( ! result . ok )
183+ throw new Error ( result . error )
184+ embedIframe ( entry , resultUrl ( result ) )
139185 } )
140186 . catch ( ( err : Error ) => {
141187 rt . status = 'error'
@@ -144,19 +190,30 @@ function initDock(rpc: Awaited<ReturnType<typeof connectDevframe>>, entry: Ifram
144190 } )
145191}
146192
193+ /** A plain iframe dock (the live terminals plugin) mounts its URL directly. */
194+ function openIframe ( entry : IframeDock ) {
195+ const rt = runtimeFor ( entry . id )
196+ if ( rt . status !== 'idle' )
197+ return
198+ rt . status = 'starting'
199+ embedIframe ( entry , entry . url )
200+ }
201+
147202async function main ( ) {
148203 setStatus ( 'Connecting…' )
149- const rpc = await connectDevframe ( { baseURL : HUB_BASE } )
204+ rpc = await connectDevframe ( { baseURL : HUB_BASE } )
150205 setStatus ( `Connected · backend=${ rpc . connectionMeta . backend } ` , 'ready' )
151206
152207 const switchTo = ( id : string ) => {
153- if ( ! docks . some ( d => d . id === id ) )
208+ const entry = docks . find ( d => d . id === id )
209+ if ( ! entry )
154210 return
155211 selectedId = id
156212 renderSidebar ( )
157- const rt = runtimeFor ( id )
158- if ( rt . status === 'idle' )
159- initDock ( rpc , docks . find ( d => d . id === id ) ! )
213+ // Plain iframe docks open on select; launcher docks wait for their Start
214+ // button (the lazy trigger) — so opening a Storybook dock doesn't spawn it.
215+ if ( isIframeDock ( entry ) )
216+ openIframe ( entry )
160217 updateStage ( )
161218 }
162219
@@ -180,20 +237,21 @@ async function main() {
180237 } ) . join ( '' )
181238 }
182239
183- // Docks — read from `devframe:docks` shared state.
240+ // Docks — read from `devframe:docks` shared state, keeping launcher (Storybook)
241+ // and iframe (live plugin) entries.
184242 const docksState = await rpc . sharedState . get < DevframeDockEntry [ ] > ( 'devframe:docks' , { initialValue : [ ] } )
185243 const syncDocks = ( ) => {
186- docks = ( docksState . value ( ) ?? [ ] ) . filter ( isIframeDock )
244+ docks = ( docksState . value ( ) ?? [ ] ) . filter ( ( d ) : d is Dock => isIframeDock ( d ) || isLauncherDock ( d ) )
187245 if ( selectedId && ! docks . some ( d => d . id === selectedId ) )
188246 selectedId = null
189247 if ( ! selectedId && docks . length )
190248 selectedId = docks [ 0 ] . id
191249 renderSidebar ( )
192- if ( selectedId ) {
193- const rt = runtimeFor ( selectedId )
194- if ( rt . status === 'idle' )
195- initDock ( rpc , docks . find ( d => d . id === selectedId ) ! )
196- }
250+ // Auto-open plain iframe docks (terminals plugin); launcher docks stay idle
251+ // until the user starts them.
252+ const entry = selectedId ? docks . find ( d => d . id === selectedId ) : undefined
253+ if ( entry && isIframeDock ( entry ) && runtimeFor ( entry . id ) . status === 'idle' )
254+ openIframe ( entry )
197255 updateStage ( )
198256 }
199257 docksState . on ( 'updated' , syncDocks )
@@ -203,6 +261,24 @@ async function main() {
203261 if ( target ?. dataset . dockId )
204262 switchTo ( target . dataset . dockId )
205263 } )
264+
265+ overlayEl . addEventListener ( 'click' , ( event ) => {
266+ const el = event . target as HTMLElement
267+ const launchId = el . closest < HTMLButtonElement > ( 'button[data-launch]' ) ?. dataset . launch
268+ if ( launchId ) {
269+ const entry = docks . find ( d => d . id === launchId )
270+ if ( entry && isLauncherDock ( entry ) )
271+ launch ( entry )
272+ return
273+ }
274+ if ( el . closest ( 'button[data-terminals]' ) ) {
275+ // Jump to the live terminals plugin to watch the spawned dev server stream.
276+ const terminals = docks . find ( d => isIframeDock ( d ) && ( d . category ?? '' ) === 'Plugins' )
277+ if ( terminals )
278+ switchTo ( terminals . id )
279+ }
280+ } )
281+
206282 syncDocks ( )
207283}
208284
0 commit comments