diff --git a/apps/ui/src/components/site-overview-view/index.test.tsx b/apps/ui/src/components/site-overview-view/index.test.tsx index 3837e8e6be..6d1068f7a2 100644 --- a/apps/ui/src/components/site-overview-view/index.test.tsx +++ b/apps/ui/src/components/site-overview-view/index.test.tsx @@ -18,6 +18,7 @@ import { } from '@/data/queries/use-sites'; import { useWordPressVersions, useWpVersion } from '@/data/queries/use-wordpress-versions'; import { useOffline } from '@/hooks/use-offline'; +import { useThemeDetails } from '@/hooks/use-theme-details'; import styles from './style.module.css'; import { SiteOverviewView } from './index'; import type { SiteDetails } from '@/data/core'; @@ -97,6 +98,10 @@ vi.mock( '@/hooks/use-offline', () => ( { useOffline: vi.fn(), } ) ); +vi.mock( '@/hooks/use-theme-details', () => ( { + useThemeDetails: vi.fn(), +} ) ); + vi.mock( '@/hooks/use-sidebar-collapsed', () => ( { useSidebarCollapsed: useSidebarCollapsedMock, } ) ); @@ -118,6 +123,7 @@ const useSitesMock = vi.mocked( useSites, { partial: true } ); const useStartSiteMock = vi.mocked( useStartSite, { partial: true } ); const useUpdateSiteMock = vi.mocked( useUpdateSite, { partial: true } ); const useOfflineMock = vi.mocked( useOffline ); +const useThemeDetailsMock = vi.mocked( useThemeDetails ); const useWordPressVersionsMock = vi.mocked( useWordPressVersions, { partial: true } ); const useWpVersionMock = vi.mocked( useWpVersion, { partial: true } ); const useXdebugEnabledSiteMock = vi.mocked( useXdebugEnabledSite, { partial: true } ); @@ -150,6 +156,9 @@ describe( 'SiteOverviewView', () => { } ); useConnectorMock.mockReturnValue( { openSiteUrl } ); + useThemeDetailsMock.mockImplementation( ( site ) => + site.themeDetails ? { state: 'ready', details: site.themeDetails } : { state: 'unknown' } + ); useAgenticFeaturesMock.mockReturnValue( { enabled: true, chatEnabled: true, @@ -476,6 +485,26 @@ describe( 'SiteOverviewView', () => { expect( screen.queryByText( 'Site Editor' ) ).not.toBeInTheDocument(); } ); + it( 'holds theme-dependent shortcuts while theme details load', () => { + useThemeDetailsMock.mockReturnValue( { state: 'loading' } ); + + const { container } = renderView(); + + expect( screen.queryByText( 'Site Editor' ) ).not.toBeInTheDocument(); + expect( screen.queryByText( 'Customizer' ) ).not.toBeInTheDocument(); + expect( screen.queryByText( 'Media Library' ) ).not.toBeInTheDocument(); + expect( container.querySelectorAll( `.${ styles.buttonSkeleton }` ) ).toHaveLength( 7 ); + } ); + + it( 'retains the Classic fallback when theme details are unavailable', () => { + useThemeDetailsMock.mockReturnValue( { state: 'unknown' } ); + + renderView(); + + expect( screen.getByText( 'Customizer' ) ).toBeVisible(); + expect( screen.queryByText( 'Site Editor' ) ).not.toBeInTheDocument(); + } ); + // Rendered without a SessionUIProvider, so the open-site-url hook takes // its browser fallback path; inside the app these open the preview panel. it( 'routes shortcuts through the connector', async () => { diff --git a/apps/ui/src/components/site-overview-view/index.tsx b/apps/ui/src/components/site-overview-view/index.tsx index 96c242a8fe..c6ab7bad4d 100644 --- a/apps/ui/src/components/site-overview-view/index.tsx +++ b/apps/ui/src/components/site-overview-view/index.tsx @@ -26,6 +26,7 @@ import { useIsSiteStarting, useIsSiteStopping, useSites } from '@/data/queries/u import { useOpenSiteUrl } from '@/hooks/use-open-site-url'; import { useSidebarCollapsed } from '@/hooks/use-sidebar-collapsed'; import { useSiteManagementActions } from '@/hooks/use-site-management-actions'; +import { useThemeDetails } from '@/hooks/use-theme-details'; import { useTrafficLightSpace } from '@/hooks/use-traffic-light-space'; import styles from './style.module.css'; import type { SiteSettingsTabId } from '@/components/site-settings-view'; @@ -105,11 +106,25 @@ function OverviewButton( { ); } -function ButtonSection( { title, children }: { title: string; children: ReactNode } ) { +function ButtonSection( { + title, + loadingCount = 0, + children, +}: { + title: string; + loadingCount?: number; + children: ReactNode; +} ) { return (

{ title }

-
{ children }
+
0 }> + { loadingCount > 0 + ? Array.from( { length: loadingCount }, ( _, index ) => ( +
+ ) ) + : children } +
); } @@ -166,7 +181,8 @@ function SiteOverviewBody( { } ); const busy = isStarting || isStopping; - const themeDetails = site.themeDetails; + const themeStatus = useThemeDetails( site ); + const themeDetails = themeStatus.state === 'ready' ? themeStatus.details : undefined; const isBlockTheme = themeDetails?.isBlockTheme === true; // Opens WordPress screens in the in-app preview panel (starting the site @@ -200,7 +216,10 @@ function SiteOverviewBody( {
- + { isBlockTheme ? ( <> { + // `false` skips the loading event consumed by Classic; this UI tracks + // the same request through React Query. + return ( await ipcApi.loadThemeDetails( siteId, false ) ) as SiteDetails[ 'themeDetails' ]; + }, + async exportFullSite( siteId ): Promise< string | null > { const sites = ( await ipcApi.getSiteDetails() ) as SiteDetails[]; const site = sites.find( ( candidate ) => candidate.id === siteId ); diff --git a/apps/ui/src/data/core/types.ts b/apps/ui/src/data/core/types.ts index a913465c32..40c67af38c 100644 --- a/apps/ui/src/data/core/types.ts +++ b/apps/ui/src/data/core/types.ts @@ -196,6 +196,9 @@ export interface Connector { // Cached screenshot thumbnail captured by the desktop app while the site // was running. Returns null when the site has not produced a thumbnail yet. getSiteThumbnail( siteId: string ): Promise< string | null >; + // Resolves active theme details when the host exposes that capability. + // Desktop reuses the same IPC flow as the Classic UI. + getThemeDetails?( siteId: string ): Promise< SiteDetails[ 'themeDetails' ] >; // Exports a site as a full backup archive (files + database). Prompts the // user for a destination via a save-as dialog; resolves with the chosen diff --git a/apps/ui/src/hooks/use-theme-details.ts b/apps/ui/src/hooks/use-theme-details.ts new file mode 100644 index 0000000000..7a44dc9aa8 --- /dev/null +++ b/apps/ui/src/hooks/use-theme-details.ts @@ -0,0 +1,52 @@ +import { useQuery } from '@tanstack/react-query'; +import { useConnector } from '@/data/core'; +import type { SiteDetails } from '@/data/core'; + +export type ThemeDetails = NonNullable< SiteDetails[ 'themeDetails' ] >; + +/** + * Whether a site's theme is known yet. "Unknown" is a real outcome, not an + * error: a host without theme inspection, or a stopped site without persisted + * details, resolves to that state rather than loading forever. + */ +export type ThemeDetailsStatus = + | { state: 'loading' } + | { state: 'ready'; details: ThemeDetails } + | { state: 'unknown' }; + +export const themeDetailsQueryKey = ( siteId: string ) => [ 'theme-details', siteId ] as const; + +/** + * The site's active theme, resolving through the host when the site list did + * not already carry the persisted details. Desktop delegates to the same + * `loadThemeDetails` IPC handler as Classic Studio. + */ +export function useThemeDetails( site: SiteDetails ): ThemeDetailsStatus { + const connector = useConnector(); + const persisted = site.themeDetails; + const canResolve = site.running && Boolean( connector.getThemeDetails ); + + const query = useQuery( { + queryKey: themeDetailsQueryKey( site.id ), + // React Query rejects `undefined` as data, and "the host doesn't know" + // is a legitimate answer here, so it travels as null. + queryFn: async () => ( await connector.getThemeDetails?.( site.id ) ) ?? null, + enabled: ! persisted && canResolve, + staleTime: 30_000, + retry: false, + } ); + + if ( persisted ) { + return { state: 'ready', details: persisted }; + } + if ( query.data ) { + return { state: 'ready', details: query.data }; + } + if ( ! canResolve ) { + return { state: 'unknown' }; + } + if ( query.isPending && ! query.isError ) { + return { state: 'loading' }; + } + return { state: 'unknown' }; +}