Skip to content
Open
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
29 changes: 29 additions & 0 deletions apps/ui/src/components/site-overview-view/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
} ) );
Expand All @@ -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 } );
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 () => {
Expand Down
27 changes: 23 additions & 4 deletions apps/ui/src/components/site-overview-view/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
<section className={ styles.buttonSection }>
<h2>{ title }</h2>
<div className={ styles.buttonGrid }>{ children }</div>
<div className={ styles.buttonGrid } aria-busy={ loadingCount > 0 }>
{ loadingCount > 0
? Array.from( { length: loadingCount }, ( _, index ) => (
<div key={ index } className={ styles.buttonSkeleton } />
) )
: children }
</div>
</section>
);
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -200,7 +216,10 @@ function SiteOverviewBody( {
<OfflineBanner />
<AgenticSigninBanner />
<div className={ styles.actionsColumn }>
<ButtonSection title={ __( 'Customize' ) }>
<ButtonSection
title={ __( 'Customize' ) }
loadingCount={ themeStatus.state === 'loading' ? 7 : 0 }
>
{ isBlockTheme ? (
<>
<OverviewButton
Expand Down
24 changes: 24 additions & 0 deletions apps/ui/src/components/site-overview-view/style.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,30 @@
color: var(--wpds-color-fg-content-error, var(--wpds-color-fg-content-neutral));
}

.buttonSkeleton {
width: 96px;
height: 32px;
border-radius: 4px;
background: var(--wpds-color-bg-surface-neutral-weak);
animation: buttonSkeletonPulse 1.6s ease-in-out infinite;
}

@keyframes buttonSkeletonPulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.55;
}
}

@media (prefers-reduced-motion: reduce) {
.buttonSkeleton {
animation: none;
}
}

@media (max-width: 760px) {
.content {
padding: var(--wpds-dimension-padding-xl) var(--wpds-dimension-padding-md)
Expand Down
6 changes: 6 additions & 0 deletions apps/ui/src/data/core/connectors/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,12 @@ export function createIpcConnector(): Connector {
return ( await ipcApi.getThumbnailData( siteId ) ) as string | null;
},

async getThemeDetails( siteId ): Promise< SiteDetails[ 'themeDetails' ] > {
// `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 );
Expand Down
3 changes: 3 additions & 0 deletions apps/ui/src/data/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions apps/ui/src/hooks/use-theme-details.ts
Original file line number Diff line number Diff line change
@@ -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' };
}