/** * `@huaqiu/dsh-tool-symbol-footprint` — Component Gen workspace (browser half). * * Two sidebar entry rows (封装生成 / Symbol 生成), injected between the shell's * New Session button and the workspace browser (task-board style), + one * `shell.overlay` that draws the `@huaqiu/component-gen-app` workspace. A * single shared `{ open, page }` state drives both rows and the overlay, and * the app is the single HIL driver — generation runs through the plugin's own * webServer routes (`/api/v1/huaqiu/component-gen/*`) with no agent tool * involved. * * Copy follows the host UI language: the sidebar rows translate through the * app's copy pack (`app.footprintTitle` / `app.symbolTitle` / tooltips) and * re-apply whenever `` changes (`dsh-client-locale` rewrites the * attribute), so both dsh web and the HQ Edge host stay in sync without a * `locale` service dependency. The language is resolved when the rows are * mounted and re-read on every re-application — never captured when this * bundle is evaluated, which happens before dsh resolves the preference and * can otherwise pin the copy to the served markup's `en` until the user * switches languages by hand. * * Auth is the `huaqiuAuth` CLIENT service (structural, never imported): the * same sanctioned source the GenHit card uses. The ports adapter only * consumes its public surface. */ import { useCallback, useEffect, useState, type CSSProperties } from 'react' import { ComponentGenApp, createHttpPorts, injectAppStyles, translateFor, type ComponentGenAuthPort, type ComponentGenPage, type ComponentGenPorts, } from '@huaqiu/component-gen-app' import { FOOTPRINT_ENTRY_SELECTOR, FOOTPRINT_ICON, SYMBOL_ENTRY_SELECTOR, SYMBOL_ICON, mountComponentGenSidebarEntries, } from './sidebar-entry.js' import type { HuaqiuAuthClientService } from './index.js' import { placeSupportOf, type HqEdgePlaceLike } from './place.js' /** Shared workspace state for the two sidebar actions + the overlay. */ interface WorkspaceState { open: boolean page: ComponentGenPage } const listeners = new Set<() => void>() const state: WorkspaceState = { open: false, page: 'footprint' } function announce(): void { for (const fn of [...listeners]) { try { fn() } catch { /* one bad subscriber must not strand the rest */ } } } /** Subscribe to workspace state changes (active bridge for sidebar entries). */ export function subscribeWorkspace(listener: () => void): () => void { listeners.add(listener) return () => { listeners.delete(listener) } } function setOpen(open: boolean): void { if (state.open === open) return state.open = open announce() } function openPage(page: ComponentGenPage): void { state.page = page state.open = true announce() } /** Toggle: close when already open on this page, otherwise open it. */ function togglePage(page: ComponentGenPage): void { if (state.open && state.page === page) setOpen(false) else openPage(page) } function useWorkspaceState(): WorkspaceState { const [, force] = useState(0) useEffect(() => { const listener = (): void => force((n) => n + 1) listeners.add(listener) return () => { listeners.delete(listener) } }, []) return { open: state.open, page: state.page } } /** Host UI language from `` (same heuristic as the GenHit theme). */ function detectLocale(): string | undefined { if (typeof document !== 'undefined') { const tag = document.documentElement?.getAttribute('lang') if (tag) { const primary = tag.toLowerCase().split('-')[0] if (primary === 'zh' || primary === 'en') return primary } } return undefined } /** * Last language the rows were announced for — change detection only, never * the copy source (see {@link resolveLang}). */ let currentLang: string | undefined = detectLocale() /** * Resolve the UI language the sidebar copy is rendered in. * * Read live from `` on every call instead of returning the * `currentLang` snapshot: this module is evaluated when its bundle script * loads, which — for a plugin that waits on injected services before `apply()` * mounts anything — can be long before dsh resolves the stored language * preference and rewrites the attribute (the served markup declares `en`). * A snapshot taken at evaluation time keeps the wrong language even after an * observer is running, because the rewrite it missed is already over by then. */ function resolveLang(): string | undefined { return detectLocale() } let localeObserver: MutationObserver | null = null /** Live `startLocaleObserver` callers: the observer is shared, so it dies with the LAST one. */ let localeObserverUsers = 0 /** * Watch `` — `dsh-client-locale` rewrites it on every language * change (dsh web) and HQ Edge mirrors it too. On change, re-resolve the * language and announce so the sidebar rows re-apply their localized labels * and the open overlay re-renders with the new language. * * @returns disposer for THIS caller; the shared observer is only disconnected * once every caller has disposed (a reload applies the new instance before * disposing the old one, and the old disposer must not silence the new one). */ function startLocaleObserver(): () => void { if (typeof document === 'undefined' || typeof MutationObserver === 'undefined' || !document.documentElement) { return () => {} } // Re-read before observing: a rewrite that landed between module evaluation // and this call happened while nothing was watching, and no later mutation // is guaranteed (dsh writes the resolved language once at activation). // Reading here and observing here closes the window from both sides — an // earlier rewrite is picked up by the read, a later one by the observer. currentLang = detectLocale() if (localeObserver === null) { localeObserver = new MutationObserver(() => { const next = detectLocale() if (next !== currentLang) { currentLang = next announce() } }) localeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] }) } localeObserverUsers += 1 return () => { localeObserverUsers -= 1 if (localeObserverUsers <= 0 && localeObserver !== null) { localeObserverUsers = 0 localeObserver.disconnect() localeObserver = null } } } /** * Build the workspace ports: component-gen HTTP + dsh-auth client auth. * * When the host provides `hqEdge` (edge-bridge browser half, HQ Edge-hosted * DSH only), a Place port is attached so generated symbols/footprints can be * sent straight into the editor. Editor compatibility is resolved lazily at * every call via the shared frontend matrix — standalone DSH (no `hqEdge` * service) simply gets no Place capability. */ export function createWorkspacePorts( auth: HuaqiuAuthClientService['auth'] | undefined, getHqEdge?: () => HqEdgePlaceLike | undefined, track?: ComponentGenPorts['track'], ): ComponentGenPorts { const authPort: ComponentGenAuthPort = { isAuthenticated: async () => auth?.isAuthenticated() ?? false, getUserInfo: async () => { try { return (await auth?.getUserInfo()) ?? null } catch { return null } }, login: async () => { try { await auth?.login?.() } catch { /* dsh-auth owns the dialog */ } }, onAuthStateChanged: (cb) => { if (!auth?.onAuthStateChanged) return () => {} return auth.onAuthStateChanged((info) => cb(!!info)) }, } const place = getHqEdge ? { canPlace: (type: 'symbol' | 'footprint'): boolean => placeSupportOf(getHqEdge, type)?.() != null, placeArtifact: async (request: { type: 'symbol' | 'footprint'; artifactUri: string; filename?: string }) => { const support = placeSupportOf(getHqEdge, request.type)?.() if (!support) throw new Error('placement unavailable in the current editor') return support.place(request) }, } : undefined const ports = createHttpPorts({ base: '/api/v1/huaqiu/component-gen', artifactsBase: '/api/v1/huaqiu/artifacts', auth: authPort, ...(place ? { place } : {}), }) return track ? { ...ports, track } : ports } /** Full-viewport overlay panel hosting the app. */ function WorkspaceOverlay({ ports }: { ports: ComponentGenPorts }): JSX.Element { const { open, page } = useWorkspaceState() const lang = detectLocale() const close = useCallback(() => setOpen(false), []) // Esc closes the workspace popup, matching the backdrop-click close. Active // only while the overlay is open; removed on close/unmount. useEffect(() => { if (!open) return const onKeyDown = (event: KeyboardEvent): void => { if (event.key === 'Escape') close() } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) }, [open, close]) if (!open) return <>> // Chrome follows dsh's Modal primitive (which the settings dialog shares): // mask-1 + mask-blur overlay, layer-2 surface with elevation-prominent, // r24 panel, l2 scrollbar rebinding. const panelStyle = { position: 'relative' as const, zIndex: 1, display: 'flex', flexDirection: 'column', width: 'min(880px, 100%)', height: 'min(860px, 100%)', overflow: 'hidden', borderRadius: '24px', background: 'var(--dsw-alias-bg-layer-2)', boxShadow: 'var(--dsw-elevation-prominent)', '--dsh-scrollbar-thumb': 'var(--dsw-alias-scrollbar-bg-l2)', '--dsh-scrollbar-thumb-hover': 'var(--dsw-alias-scrollbar-hover-l2)', } as CSSProperties return (