diff --git a/apps/studio/src/components/content-tab-overview.tsx b/apps/studio/src/components/content-tab-overview.tsx index 3a1c17ec31..6055a8931b 100644 --- a/apps/studio/src/components/content-tab-overview.tsx +++ b/apps/studio/src/components/content-tab-overview.tsx @@ -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 (

{ title }

-
+
); }; @@ -222,7 +220,7 @@ export function ContentTabOverview( { selectedSite }: ContentTabOverviewProps )
- { loading &&
} + { loading &&
} { ! loading && ! isThumbnailError &&

{ themeDetails?.name }

}
diff --git a/apps/studio/src/hooks/sync-sites/use-listen-deep-link-connection.ts b/apps/studio/src/hooks/sync-sites/use-listen-deep-link-connection.ts index f329986bfe..8a9151a1af 100644 --- a/apps/studio/src/hooks/sync-sites/use-listen-deep-link-connection.ts +++ b/apps/studio/src/hooks/sync-sites/use-listen-deep-link-connection.ts @@ -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(); @@ -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(); } ); } diff --git a/apps/studio/src/index.css b/apps/studio/src/index.css index b4d5e8ca62..2ae0ef7a88 100644 --- a/apps/studio/src/index.css +++ b/apps/studio/src/index.css @@ -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 { diff --git a/apps/studio/src/ipc-utils.ts b/apps/studio/src/ipc-utils.ts index 25cbc40425..3b30b915e9 100644 --- a/apps/studio/src/ipc-utils.ts +++ b/apps/studio/src/ipc-utils.ts @@ -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' ] } ]; diff --git a/apps/studio/src/modules/sync/components/sync-connected-sites.tsx b/apps/studio/src/modules/sync/components/sync-connected-sites.tsx index 44eff2fdd1..71541c27b1 100644 --- a/apps/studio/src/modules/sync/components/sync-connected-sites.tsx +++ b/apps/studio/src/modules/sync/components/sync-connected-sites.tsx @@ -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'; @@ -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, @@ -263,19 +266,30 @@ const SyncConnectedSitesSectionItem = ( { key={ connectedSite.id } >
- + { isSiteLoading ? ( +
+ ) : ( + + ) }
- + { isSiteLoading ? ( +
+ ) : ( + + ) }
{ isPulling && ( @@ -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 = ; - if ( hasConnectionErrors ) { + if ( isSiteLoading ) { + logo =
; + } else if ( hasConnectionErrors ) { logo = ; } else if ( connectedSite.isPressable ) { logo = ; @@ -557,9 +575,13 @@ const SyncConnectedSiteSection = ( {
{ logo } -
- { connectedSite.name } -
+ { isSiteLoading ? ( +
+ ) : ( +
+ { connectedSite.name } +
+ ) }
void; } ) { const { __ } = useI18n(); + const loadingSiteIds = useRootSelector( connectedSitesSelectors.selectLoadingSiteIds ); + const isSiteLoading = loadingSiteIds.includes( site.id ); const isAlreadyConnected = site.syncSupport === 'already-connected'; const isSyncable = site.syncSupport === 'syncable'; const isNeedsTransfer = site.syncSupport === 'needs-transfer'; @@ -285,48 +290,61 @@ function SiteItem( { } } >
-
- { isPressable && ( - - - - ) } - { ! isPressable && ( - - - - ) } - { site.name } -
- + { isSiteLoading ? ( +
+
+
+
+ ) : ( +
+ { isPressable ? ( + + + + ) : ( + + + + ) } + { site.name } +
+ ) } + { isSiteLoading ? ( +
+ ) : ( + + ) }
{ isSyncable && (
diff --git a/apps/studio/src/modules/sync/components/tree-view-loading-skeleton.tsx b/apps/studio/src/modules/sync/components/tree-view-loading-skeleton.tsx index b5e33efaaa..8d9929dcb3 100644 --- a/apps/studio/src/modules/sync/components/tree-view-loading-skeleton.tsx +++ b/apps/studio/src/modules/sync/components/tree-view-loading-skeleton.tsx @@ -1,15 +1,11 @@ -import { cx } from 'src/lib/cx'; - export const TreeViewLoadingSkeleton = () => { - const skeletonBg = 'animate-pulse bg-gradient-to-r from-[#F6F7F7] via-[#DCDCDE] to-[#F6F7F7]'; - return (
{ [ 1, 2 ].map( ( key ) => (
-
-
+
+
) ) } diff --git a/apps/studio/src/modules/sync/index.tsx b/apps/studio/src/modules/sync/index.tsx index 8a88c56824..7a890037d7 100644 --- a/apps/studio/src/modules/sync/index.tsx +++ b/apps/studio/src/modules/sync/index.tsx @@ -132,10 +132,11 @@ export function ContentTabSync( { selectedSite }: { selectedSite: SiteDetails } connectedSitesSelectors.selectSelectedRemoteSiteId ); const { isAuthenticated, user } = useAuth(); - const { data: connectedSites = [] } = useGetConnectedSitesForLocalSiteQuery( { - localSiteId: selectedSite.id, - userId: user?.id, - } ); + const { data: connectedSites = [], isLoading: isLoadingConnectedSites } = + useGetConnectedSitesForLocalSiteQuery( { + localSiteId: selectedSite.id, + userId: user?.id, + } ); const [ connectSite ] = useConnectSiteMutation(); const [ disconnectSite ] = useDisconnectSiteMutation(); const { pushSite, pullSite } = useSyncSites(); @@ -214,7 +215,7 @@ export function ContentTabSync( { selectedSite }: { selectedSite: SiteDetails }
- ) : ( + ) : isLoadingConnectedSites ? null : (
{ state.selectedRemoteSiteId = null; }, + + addLoadingSiteId: ( state, action: PayloadAction< number > ) => { + if ( ! state.loadingSiteIds.includes( action.payload ) ) { + state.loadingSiteIds.push( action.payload ); + } + }, + + removeLoadingSiteId: ( state, action: PayloadAction< number > ) => { + state.loadingSiteIds = state.loadingSiteIds.filter( ( id ) => id !== action.payload ); + }, }, } ); @@ -50,6 +62,7 @@ export const connectedSitesSelectors = { selectIsModalOpen: ( state: RootState ) => state.connectedSites.isModalOpen, selectModalMode: ( state: RootState ) => state.connectedSites.modalMode, selectSelectedRemoteSiteId: ( state: RootState ) => state.connectedSites.selectedRemoteSiteId, + selectLoadingSiteIds: ( state: RootState ) => state.connectedSites.loadingSiteIds, }; export const connectedSitesApi = createApi( { diff --git a/apps/studio/src/stores/sync/wpcom-sites.ts b/apps/studio/src/stores/sync/wpcom-sites.ts index e794abbe49..9d1f634b56 100644 --- a/apps/studio/src/stores/sync/wpcom-sites.ts +++ b/apps/studio/src/stores/sync/wpcom-sites.ts @@ -117,11 +117,78 @@ function transformSitesResponse( sites: unknown[], connectedSiteIds?: number[] ) } ); } +const SITE_FIELDS = [ + 'name', + 'ID', + 'URL', + 'plan', + 'capabilities', + 'is_wpcom_atomic', + 'options', + 'jetpack', + 'is_deleted', + 'is_a8c', + 'hosting_provider_guess', + 'environment_type', +].join( ',' ); + export const wpcomSitesApi = createApi( { reducerPath: 'wpcomSitesApi', baseQuery: fetchBaseQuery(), tagTypes: [ 'WpComSites' ], endpoints: ( builder ) => ( { + getSingleWpComSite: builder.query< SyncSite, { siteId: number; userId?: number } >( { + queryFn: async ( { siteId } ) => { + const wpcomClient = getWpcomClient(); + if ( ! wpcomClient ) { + return { error: { status: 401, data: 'Not authenticated' } }; + } + + try { + const response = await wpcomClient.req.get( + { + apiNamespace: 'rest/v1.1', + path: `/sites/${ siteId }`, + }, + { + fields: SITE_FIELDS, + options: 'created_at,wpcom_staging_blog_ids', + } + ); + + const parsedSite = sitesEndpointSiteSchema.parse( response ); + + const allConnectedSites = await getIpcApi().getConnectedWpcomSites(); + + // Determine if staging by checking environment_type (can't access parent site's staging IDs without fetching /me/sites) + const isStaging = + parsedSite.environment_type === 'staging' || + parsedSite.environment_type === 'development'; + + const syncSupport = getSyncSupport( + parsedSite, + allConnectedSites.map( ( { id } ) => id ) + ); + + const syncSite = transformSingleSiteResponse( parsedSite, syncSupport, isStaging ); + + return { data: syncSite }; + } catch ( error ) { + Sentry.captureException( error ); + console.error( error ); + return { + error: { + status: 500, + data: error, + }, + }; + } + }, + providesTags: ( _result, _error, arg ) => [ + { type: 'WpComSites', userId: arg.userId }, + { type: 'WpComSites', id: arg.siteId }, + ], + } ), getWpComSites: builder.query< SyncSite[], { connectedSiteIds?: number[]; userId?: number } >( { queryFn: async ( { connectedSiteIds } ) => { const wpcomClient = getWpcomClient(); @@ -132,28 +199,13 @@ export const wpcomSitesApi = createApi( { try { const allConnectedSites = await getIpcApi().getConnectedWpcomSites(); - const fields = [ - 'name', - 'ID', - 'URL', - 'plan', - 'capabilities', - 'is_wpcom_atomic', - 'options', - 'jetpack', - 'is_deleted', - 'is_a8c', - 'hosting_provider_guess', - 'environment_type', - ].join( ',' ); - const response = await wpcomClient.req.get( { apiNamespace: 'rest/v1.2', path: `/me/sites`, }, { - fields, + fields: SITE_FIELDS, filter: 'atomic,wpcom', options: 'created_at,wpcom_staging_blog_ids', site_activity: 'active',