Skip to content
Open
8 changes: 3 additions & 5 deletions apps/studio/src/components/content-tab-overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,11 @@ interface ContentTabOverviewProps {
selectedSite: SiteDetails;
}

const skeletonBg = 'animate-pulse bg-gradient-to-r from-[#F6F7F7] via-[#DCDCDE] to-[#F6F7F7]';

const ButtonSectionSkeleton = ( { title }: { title: string } ) => {
return (
<div className="w-full max-w-96">
<h2 className="a8c-subtitle-small mb-3">{ title }</h2>
<div className={ `w-full h-20 my-1 ${ skeletonBg }` }></div>
<div className="w-full h-20 my-1 skeleton-bg"></div>
</div>
);
};
Expand Down Expand Up @@ -222,7 +220,7 @@ export function ContentTabOverview( { selectedSite }: ContentTabOverviewProps )
<div
className={ cx(
'w-full min-h-40 max-h-64 rounded-sm border border-a8c-gray-5 bg-a8c-gray-0 mb-2 flex justify-center',
loading && `h-64 ${ skeletonBg }`,
loading && 'h-64 skeleton-bg',
isThumbnailError && 'border-none',
! loading && 'hover:border-a8c-blue-50 duration-300'
) }
Expand Down Expand Up @@ -259,7 +257,7 @@ export function ContentTabOverview( { selectedSite }: ContentTabOverviewProps )
) }
</div>
<div className="flex justify-between items-center w-full">
{ loading && <div className={ `w-[100px] min-h-4 ${ skeletonBg }` }></div> }
{ loading && <div className="w-[100px] min-h-4 skeleton-bg"></div> }
{ ! loading && ! isThumbnailError && <p>{ themeDetails?.name }</p> }
</div>
</div>
Expand Down
102 changes: 83 additions & 19 deletions apps/studio/src/hooks/sync-sites/use-listen-deep-link-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ import { useAuth } from 'src/hooks/use-auth';
import { useContentTabs } from 'src/hooks/use-content-tabs';
import { useIpcListener } from 'src/hooks/use-ipc-listener';
import { useSiteDetails } from 'src/hooks/use-site-details';
import { getIpcApi } from 'src/lib/get-ipc-api';
import { SyncSite } from 'src/modules/sync/types';
import { useAppDispatch } from 'src/stores';
import {
connectedSitesActions,
connectedSitesApi,
useConnectSiteMutation,
useGetConnectedSitesForLocalSiteQuery,
} from 'src/stores/sync/connected-sites';
import { useGetWpComSitesQuery } from 'src/stores/sync/wpcom-sites';
import { wpcomSitesApi, useGetWpComSitesQuery } from 'src/stores/sync/wpcom-sites';

export function useListenDeepLinkConnection() {
const dispatch = useAppDispatch();
Expand All @@ -28,26 +31,87 @@ export function useListenDeepLinkConnection() {

useIpcListener(
'sync-connect-site',
async ( _event, { remoteSiteId, studioSiteId, autoOpenPush } ) => {
// Fetch latest sites from network before checking
const result = await refetchWpComSites();
const latestSites = result.data ?? [];
const newConnectedSite = latestSites.find( ( site ) => site.id === remoteSiteId );
if ( newConnectedSite ) {
if ( selectedSite?.id && selectedSite.id !== studioSiteId ) {
// Select studio site that started the sync
setSelectedSiteId( studioSiteId );
}
await connectSite( { site: newConnectedSite, localSiteId: studioSiteId } );
if ( selectedTab !== 'sync' ) {
// Switch to sync tab
setSelectedTab( 'sync' );
}
// Only auto-open push dialog if explicitly requested (e.g., from "Publish site" button)
if ( autoOpenPush ) {
dispatch( connectedSitesActions.setSelectedRemoteSiteId( remoteSiteId ) );
async (
_event,
{
remoteSiteId,
studioSiteId,
autoOpenPush,
}: {
remoteSiteId: number;
studioSiteId: string;
autoOpenPush?: boolean;
}
) => {
// Create minimal site object optimistically to connect immediately
const minimalSite: SyncSite = {
id: remoteSiteId,
localSiteId: studioSiteId,
name: '',
url: '',
isStaging: false,
isPressable: false,
environmentType: null,
syncSupport: 'already-connected',
lastPullTimestamp: null,
lastPushTimestamp: null,
};

// Switch to the site that initiated the connection if needed
if ( selectedSite?.id && selectedSite.id !== studioSiteId ) {
setSelectedSiteId( studioSiteId );
}

// Switch to sync tab
if ( selectedTab !== 'sync' ) {
setSelectedTab( 'sync' );
}

// Mark site as loading in ephemeral Redux state (not persisted to storage)
dispatch( connectedSitesActions.addLoadingSiteId( remoteSiteId ) );

const connectPromise = connectSite( { site: minimalSite, localSiteId: studioSiteId } );

// Only auto-open push dialog if explicitly requested (e.g., from "Publish site" button)
if ( autoOpenPush ) {
dispatch( connectedSitesActions.setSelectedRemoteSiteId( remoteSiteId ) );
}

const fetchSingleSitePromise = dispatch(
wpcomSitesApi.endpoints.getSingleWpComSite.initiate( {
siteId: remoteSiteId,
userId: user?.id,
} )
);

// Wait for both operations to complete
try {
const [ , singleSiteResult ] = await Promise.all( [
connectPromise,
fetchSingleSitePromise,
] );

if ( singleSiteResult.data ) {
const fullSiteData: SyncSite = {
...singleSiteResult.data,
localSiteId: studioSiteId,
};
// Use updateSingleConnectedWpcomSite to overwrite the minimal site in storage.
// connectSite only adds new sites — it won't update an existing entry.
await getIpcApi().updateSingleConnectedWpcomSite( fullSiteData );
dispatch( connectedSitesApi.util.invalidateTags( [ 'ConnectedSites' ] ) );
}
} catch ( error ) {
console.error( 'Error during site connection:', error );
} finally {
fetchSingleSitePromise.unsubscribe();
dispatch( connectedSitesActions.removeLoadingSiteId( remoteSiteId ) );
}

// Refetch all sites to update syncSites (used by the push/pull modal).
// Fired after clearing the loading state to avoid stale closure issues
// where the captured refetch references an outdated query subscription.
void refetchWpComSites();
}
);
}
5 changes: 5 additions & 0 deletions apps/studio/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
.interpolate-size-allow-keywords {
interpolate-size: allow-keywords;
}

.skeleton-bg {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
background-image: linear-gradient(to right, #f6f7f7, #dcdcde, #f6f7f7);
}
}

body {
Expand Down
8 changes: 7 additions & 1 deletion apps/studio/src/ipc-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,13 @@ export interface IpcEvents {
'snapshot-key-value': [ { operationId: crypto.UUID; data: SnapshotKeyValueEventData } ];
'snapshot-success': [ { operationId: crypto.UUID } ];
'show-whats-new': [ void ];
'sync-connect-site': [ { remoteSiteId: number; studioSiteId: string; autoOpenPush?: boolean } ];
'sync-connect-site': [
{
remoteSiteId: number;
studioSiteId: string;
autoOpenPush?: boolean;
},
];
'test-render-failure': [ void ];
'theme-details-loading': [ { id: string } ];
'theme-details-loaded': [ { id: string; details: StartedSiteDetails[ 'themeDetails' ] } ];
Expand Down
54 changes: 38 additions & 16 deletions apps/studio/src/modules/sync/components/sync-connected-sites.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,10 @@ import {
convertTreeToPushOptions,
} from 'src/modules/sync/lib/convert-tree-to-sync-options';
import { getSiteEnvironment } from 'src/modules/sync/lib/environment-utils';
import { useAppDispatch, useI18nLocale } from 'src/stores';
import { useAppDispatch, useI18nLocale, useRootSelector } from 'src/stores';
import {
connectedSitesActions,
connectedSitesSelectors,
useGetConnectedSitesForLocalSiteQuery,
} from 'src/stores/sync/connected-sites';
import type { SyncSite } from 'src/modules/sync/types';
Expand Down Expand Up @@ -194,6 +195,8 @@ const SyncConnectedSitesSectionItem = ( {
}: SyncConnectedSitesListProps ) => {
const { __ } = useI18n();
const isOffline = useOffline();
const loadingSiteIds = useRootSelector( connectedSitesSelectors.selectLoadingSiteIds );
const isSiteLoading = loadingSiteIds.includes( connectedSite.id );
const {
clearPullState,
getPullState,
Expand Down Expand Up @@ -263,19 +266,30 @@ const SyncConnectedSitesSectionItem = ( {
key={ connectedSite.id }
>
<div className="shrink-0">
<EnvironmentBadge type={ getSiteEnvironment( connectedSite ) } />
{ isSiteLoading ? (
<div
className="h-5 w-20 rounded skeleton-bg"
aria-label={ __( 'Loading environment' ) }
/>
) : (
<EnvironmentBadge type={ getSiteEnvironment( connectedSite ) } />
) }
</div>

<Button
variant="link"
className="!text-a8c-gray-70 hover:!text-a8c-blue-50 max-w-full overflow-hidden"
onClick={ () => {
getIpcApi().openURL( connectedSite.url );
} }
>
<span className="truncate">{ connectedSite.url.replace( /^https?:\/\//, '' ) }</span>{ ' ' }
<ArrowIcon />
</Button>
{ isSiteLoading ? (
<div className="h-5 w-48 rounded skeleton-bg" aria-label={ __( 'Loading site URL' ) } />
) : (
<Button
variant="link"
className="!text-a8c-gray-70 hover:!text-a8c-blue-50 max-w-full overflow-hidden"
onClick={ () => {
getIpcApi().openURL( connectedSite.url );
} }
>
<span className="truncate">{ connectedSite.url.replace( /^https?:\/\//, '' ) }</span>{ ' ' }
<ArrowIcon />
</Button>
) }

<div className="flex shrink-0 justify-self-end justify-end items-center min-h-[26px] w-80">
{ isPulling && (
Expand Down Expand Up @@ -542,12 +556,16 @@ const SyncConnectedSiteSection = ( {
}
};

const loadingSiteIds = useRootSelector( connectedSitesSelectors.selectLoadingSiteIds );
const isSiteLoading = loadingSiteIds.includes( connectedSite.id );
const hasConnectionErrors = connectedSite?.syncSupport !== 'already-connected';
const isPulling = isSiteIdPulling( selectedSite.id, connectedSite.id );
const isPushing = isSiteIdPushing( selectedSite.id, connectedSite.id );

let logo = <WordPressLogoCircle />;
if ( hasConnectionErrors ) {
if ( isSiteLoading ) {
logo = <div className="w-5 h-5 rounded-full skeleton-bg" aria-label={ __( 'Loading' ) } />;
} else if ( hasConnectionErrors ) {
logo = <CircleRedCrossIcon />;
} else if ( connectedSite.isPressable ) {
logo = <PressableLogo />;
Expand All @@ -557,9 +575,13 @@ const SyncConnectedSiteSection = ( {
<div key={ connectedSite.id } className="flex flex-col gap-2 border-b border-a8c-gray-0 py-5">
<div className="flex items-center gap-2 ps-8 pe-5">
{ logo }
<div className={ cx( 'a8c-label-semibold', hasConnectionErrors && 'error-message' ) }>
{ connectedSite.name }
</div>
{ isSiteLoading ? (
<div className="h-5 w-40 rounded skeleton-bg" aria-label={ __( 'Loading site name' ) } />
) : (
<div className={ cx( 'a8c-label-semibold', hasConnectionErrors && 'error-message' ) }>
{ connectedSite.name }
</div>
) }
<div className="ms-auto">
<Tooltip
text={ __(
Expand Down
Loading