From b374c9ca09d2e8bc5a1510f9c3ed83e2b439bb17 Mon Sep 17 00:00:00 2001 From: dereksmart Date: Fri, 15 May 2026 16:39:28 -0400 Subject: [PATCH 1/7] Add workspaces model slice What changed. Introduces the `enableWorkspaces` feature flag and a first-class `modules/workspaces` model layer. The new pure `buildStudioWorkspaces()` builder merges local Studio sites, WordPress.com sites, and connected-site metadata into one sorted workspace array with Local, Production, and Staging targets plus sync-link placeholders. Extends `SyncSite` and the WP.com transform path to preserve staging relationship metadata: `productionSiteId`, `stagingSiteIds`, `isWpcomAtomic`, `canManageOptions`, `hasStagingSiteFeature`, and the raw `is_wpcom_staging_site` flag. This lets connected staging metadata attach to the production workspace even when the full WP.com site list does not include staging details. Adds focused unit coverage for local-only, production-only, production/staging, local/remote combinations, connected staging with missing WP.com details, shared-local grouping, no duplicate local-backed workspace, no name-only grouping, and transform metadata preservation. What stayed unchanged / what is intentionally deferred. This slice does not add any sidebar, shell, preview, Dolly, sync/create, or routing UI. `enableWorkspaces` defaults off, so existing Studio behavior stays on the old local-site path. Name-only grouping is intentionally omitted from the first model slice; grouping is limited to explicit production/staging relationships and shared local site IDs. Tradeoffs and risks. The model introduces new identity and grouping rules before there is UI consuming them. The main risk is incorrect workspace grouping when WP.com metadata is partial or inconsistent. The mitigation is keeping the builder pure and heavily covered with fixture-style unit tests, while preserving raw relationship metadata for later UI slices instead of inferring relationships from names. Verification. Verified with `npx eslint --fix `, focused Vitest for the workspaces builder and WP.com transform tests, `npm run typecheck`, and `git diff --check`. --- apps/studio/src/ipc-types.d.ts | 1 + apps/studio/src/lib/feature-flags.ts | 6 + apps/studio/src/modules/workspaces/index.ts | 14 + .../lib/build-studio-workspaces.test.ts | 296 +++++++++++++++++ .../workspaces/lib/build-studio-workspaces.ts | 300 ++++++++++++++++++ apps/studio/src/modules/workspaces/types.ts | 49 +++ apps/studio/src/stores/sync/wpcom-sites.ts | 22 +- tools/common/lib/sync/sync-api.ts | 1 + tools/common/lib/sync/transform-sites.ts | 45 ++- .../common/lib/tests/transform-sites.test.ts | 117 +++++++ tools/common/types/sync.ts | 6 + 11 files changed, 842 insertions(+), 15 deletions(-) create mode 100644 apps/studio/src/modules/workspaces/index.ts create mode 100644 apps/studio/src/modules/workspaces/lib/build-studio-workspaces.test.ts create mode 100644 apps/studio/src/modules/workspaces/lib/build-studio-workspaces.ts create mode 100644 apps/studio/src/modules/workspaces/types.ts create mode 100644 tools/common/lib/tests/transform-sites.test.ts diff --git a/apps/studio/src/ipc-types.d.ts b/apps/studio/src/ipc-types.d.ts index f57b868f55..8f831bd85e 100644 --- a/apps/studio/src/ipc-types.d.ts +++ b/apps/studio/src/ipc-types.d.ts @@ -100,6 +100,7 @@ type IpcApi = { interface FeatureFlags { enableBlueprints: boolean; enableStudioCodeUi: boolean; + enableWorkspaces: boolean; } // eslint-disable-next-line @typescript-eslint/no-empty-object-type diff --git a/apps/studio/src/lib/feature-flags.ts b/apps/studio/src/lib/feature-flags.ts index a1bd010661..6b870ba5ae 100644 --- a/apps/studio/src/lib/feature-flags.ts +++ b/apps/studio/src/lib/feature-flags.ts @@ -18,6 +18,12 @@ export const FEATURE_FLAGS: Record< keyof FeatureFlags, FeatureFlagDefinition > flag: 'enableStudioCodeUi', default: false, }, + enableWorkspaces: { + label: 'Workspaces', + env: 'ENABLE_WORKSPACES', + flag: 'enableWorkspaces', + default: false, + }, } as const; export function getFeatureFlagFromEnv( flag: keyof FeatureFlags ): boolean { diff --git a/apps/studio/src/modules/workspaces/index.ts b/apps/studio/src/modules/workspaces/index.ts new file mode 100644 index 0000000000..9f835d1015 --- /dev/null +++ b/apps/studio/src/modules/workspaces/index.ts @@ -0,0 +1,14 @@ +export { + buildStudioWorkspaces, + createStudioWorkspaceId, + mergeWpcomSitesWithConnectedSites, +} from 'src/modules/workspaces/lib/build-studio-workspaces'; +export type { + BuildStudioWorkspacesInput, + LocalTarget, + RemoteTarget, + StudioWorkspace, + WorkspaceActivity, + WorkspaceSyncLink, + WorkspaceTargetId, +} from 'src/modules/workspaces/types'; diff --git a/apps/studio/src/modules/workspaces/lib/build-studio-workspaces.test.ts b/apps/studio/src/modules/workspaces/lib/build-studio-workspaces.test.ts new file mode 100644 index 0000000000..59b0930723 --- /dev/null +++ b/apps/studio/src/modules/workspaces/lib/build-studio-workspaces.test.ts @@ -0,0 +1,296 @@ +import { buildStudioWorkspaces } from 'src/modules/workspaces'; +import type { SyncSite } from '@studio/common/types/sync'; + +const createLocalSite = ( overrides: Partial< SiteDetails > = {} ): SiteDetails => + ( { + id: 'local-site-id', + name: 'Auro Atelier', + path: '/tmp/auro-atelier', + port: 8881, + running: false, + phpVersion: '8.4', + ...overrides, + } ) as SiteDetails; + +const createSyncSite = ( overrides: Partial< SyncSite > = {} ): SyncSite => ( { + id: 101, + localSiteId: '', + name: 'Auro Atelier', + url: 'https://auro.example', + isStaging: false, + isPressable: false, + syncSupport: 'syncable', + lastPullTimestamp: null, + lastPushTimestamp: null, + ...overrides, +} ); + +describe( 'buildStudioWorkspaces', () => { + it( 'builds a local-only workspace', () => { + const localSite = createLocalSite(); + + const workspaces = buildStudioWorkspaces( { localSites: [ localSite ] } ); + + expect( workspaces ).toHaveLength( 1 ); + expect( workspaces[ 0 ] ).toMatchObject( { + id: 'studio-workspace:local:local-site-id', + name: 'Auro Atelier', + targets: { + local: { siteId: 'local-site-id' }, + }, + syncLinks: [], + activity: { status: 'idle' }, + } ); + expect( workspaces[ 0 ].targets.production ).toBeUndefined(); + expect( workspaces[ 0 ].targets.staging ).toBeUndefined(); + } ); + + it( 'builds a production-only workspace', () => { + const productionSite = createSyncSite(); + + const workspaces = buildStudioWorkspaces( { wpcomSites: [ productionSite ] } ); + + expect( workspaces ).toHaveLength( 1 ); + expect( workspaces[ 0 ] ).toMatchObject( { + id: 'studio-workspace:wpcom:101', + targets: { + production: { siteId: 101 }, + }, + syncLinks: [], + } ); + expect( workspaces[ 0 ].targets.local ).toBeUndefined(); + expect( workspaces[ 0 ].targets.staging ).toBeUndefined(); + } ); + + it( 'groups production and staging targets', () => { + const productionSite = createSyncSite( { id: 101, stagingSiteIds: [ 202 ] } ); + const stagingSite = createSyncSite( { + id: 202, + name: 'Auro Atelier Staging', + url: 'https://auro-staging.example', + isStaging: true, + productionSiteId: 101, + } ); + + const workspaces = buildStudioWorkspaces( { + wpcomSites: [ productionSite, stagingSite ], + } ); + + expect( workspaces ).toHaveLength( 1 ); + expect( workspaces[ 0 ] ).toMatchObject( { + id: 'studio-workspace:wpcom:101', + targets: { + production: { siteId: 101 }, + staging: { siteId: 202 }, + }, + } ); + expect( workspaces[ 0 ].syncLinks ).toEqual( [ + { id: 'production:staging', source: 'production', target: 'staging', status: 'available' }, + ] ); + } ); + + it( 'groups local and production targets', () => { + const localSite = createLocalSite(); + const productionSite = createSyncSite( { + id: 101, + localSiteId: localSite.id, + syncSupport: 'already-connected', + } ); + + const workspaces = buildStudioWorkspaces( { + localSites: [ localSite ], + wpcomSites: [ productionSite ], + } ); + + expect( workspaces ).toHaveLength( 1 ); + expect( workspaces[ 0 ] ).toMatchObject( { + id: 'studio-workspace:wpcom:101', + targets: { + local: { siteId: 'local-site-id' }, + production: { siteId: 101 }, + }, + } ); + expect( workspaces[ 0 ].syncLinks ).toEqual( [ + { id: 'local:production', source: 'local', target: 'production', status: 'available' }, + ] ); + } ); + + it( 'groups local and staging targets', () => { + const localSite = createLocalSite(); + const stagingSite = createSyncSite( { + id: 202, + localSiteId: localSite.id, + name: 'Auro Atelier Staging', + isStaging: true, + productionSiteId: 101, + syncSupport: 'already-connected', + } ); + + const workspaces = buildStudioWorkspaces( { + localSites: [ localSite ], + wpcomSites: [ stagingSite ], + } ); + + expect( workspaces ).toHaveLength( 1 ); + expect( workspaces[ 0 ] ).toMatchObject( { + id: 'studio-workspace:wpcom:101', + targets: { + local: { siteId: 'local-site-id' }, + staging: { siteId: 202 }, + }, + } ); + expect( workspaces[ 0 ].syncLinks ).toEqual( [ + { id: 'local:staging', source: 'local', target: 'staging', status: 'available' }, + ] ); + } ); + + it( 'groups local, production, and staging targets', () => { + const localSite = createLocalSite(); + const productionSite = createSyncSite( { + id: 101, + localSiteId: localSite.id, + stagingSiteIds: [ 202 ], + syncSupport: 'already-connected', + } ); + const stagingSite = createSyncSite( { + id: 202, + localSiteId: localSite.id, + name: 'Auro Atelier Staging', + isStaging: true, + productionSiteId: 101, + syncSupport: 'already-connected', + } ); + + const workspaces = buildStudioWorkspaces( { + localSites: [ localSite ], + wpcomSites: [ productionSite, stagingSite ], + } ); + + expect( workspaces ).toHaveLength( 1 ); + expect( workspaces[ 0 ] ).toMatchObject( { + id: 'studio-workspace:wpcom:101', + targets: { + local: { siteId: 'local-site-id' }, + production: { siteId: 101 }, + staging: { siteId: 202 }, + }, + } ); + expect( workspaces[ 0 ].syncLinks ).toEqual( [ + { id: 'local:production', source: 'local', target: 'production', status: 'available' }, + { id: 'local:staging', source: 'local', target: 'staging', status: 'available' }, + { id: 'production:staging', source: 'production', target: 'staging', status: 'available' }, + ] ); + } ); + + it( 'groups production and staging by shared local site when explicit staging metadata is absent', () => { + const localSite = createLocalSite(); + const productionSite = createSyncSite( { + id: 101, + localSiteId: localSite.id, + syncSupport: 'already-connected', + } ); + const stagingSite = createSyncSite( { + id: 202, + localSiteId: localSite.id, + name: 'Auro Atelier Staging', + isStaging: true, + syncSupport: 'already-connected', + } ); + + const workspaces = buildStudioWorkspaces( { + localSites: [ localSite ], + wpcomSites: [ productionSite, stagingSite ], + } ); + + expect( workspaces ).toHaveLength( 1 ); + expect( workspaces[ 0 ] ).toMatchObject( { + id: 'studio-workspace:wpcom:101', + targets: { + local: { siteId: 'local-site-id' }, + production: { siteId: 101 }, + staging: { siteId: 202 }, + }, + } ); + } ); + + it( 'does not group production and staging by name alone', () => { + const productionSite = createSyncSite( { + id: 101, + name: 'Auro Atelier', + } ); + const stagingSite = createSyncSite( { + id: 202, + name: 'Auro Atelier Staging', + isStaging: true, + } ); + + const workspaces = buildStudioWorkspaces( { + wpcomSites: [ productionSite, stagingSite ], + } ); + + expect( workspaces ).toHaveLength( 2 ); + expect( workspaces.map( ( workspace ) => workspace.id ) ).toEqual( [ + 'studio-workspace:wpcom:101', + 'studio-workspace:wpcom:202', + ] ); + } ); + + it( 'groups connected staging when WP.com staging details are missing', () => { + const localSite = createLocalSite(); + const productionSite = createSyncSite( { + id: 101, + stagingSiteIds: [ 202 ], + } ); + const connectedStagingSite = createSyncSite( { + id: 202, + localSiteId: localSite.id, + name: 'Auro Atelier Staging', + url: 'https://auro-staging.example', + isStaging: true, + productionSiteId: 101, + syncSupport: 'already-connected', + lastPullTimestamp: '2026-05-14T12:00:00.000Z', + } ); + + const workspaces = buildStudioWorkspaces( { + localSites: [ localSite ], + wpcomSites: [ productionSite ], + connectedSites: [ connectedStagingSite ], + } ); + + expect( workspaces ).toHaveLength( 1 ); + expect( workspaces[ 0 ] ).toMatchObject( { + id: 'studio-workspace:wpcom:101', + targets: { + local: { siteId: 'local-site-id' }, + production: { siteId: 101 }, + staging: { + siteId: 202, + site: expect.objectContaining( { + localSiteId: localSite.id, + lastPullTimestamp: '2026-05-14T12:00:00.000Z', + } ), + }, + }, + } ); + } ); + + it( 'does not duplicate a local-backed workspace', () => { + const localSite = createLocalSite(); + const productionSite = createSyncSite( { + id: 101, + localSiteId: localSite.id, + syncSupport: 'already-connected', + } ); + + const workspaces = buildStudioWorkspaces( { + localSites: [ localSite ], + wpcomSites: [ productionSite ], + connectedSites: [ productionSite ], + } ); + + expect( workspaces ).toHaveLength( 1 ); + expect( workspaces[ 0 ].targets.local?.siteId ).toBe( localSite.id ); + expect( workspaces[ 0 ].targets.production?.siteId ).toBe( productionSite.id ); + } ); +} ); diff --git a/apps/studio/src/modules/workspaces/lib/build-studio-workspaces.ts b/apps/studio/src/modules/workspaces/lib/build-studio-workspaces.ts new file mode 100644 index 0000000000..1db139c02f --- /dev/null +++ b/apps/studio/src/modules/workspaces/lib/build-studio-workspaces.ts @@ -0,0 +1,300 @@ +import { sortSites } from '@studio/common/lib/sort-sites'; +import type { SyncSite } from '@studio/common/types/sync'; +import type { + BuildStudioWorkspacesInput, + RemoteTarget, + StudioWorkspace, + WorkspaceSyncLink, + WorkspaceTargetId, +} from 'src/modules/workspaces/types'; + +type WorkspaceGroup = { + remoteSites: SyncSite[]; + localSiteIds: Set< string >; + productionSiteId?: number; +}; + +const createProductionGroupKey = ( productionSiteId: number ) => `production:${ productionSiteId }`; + +const createLocalGroupKey = ( localSiteId: string ) => `local:${ localSiteId }`; + +const createRemoteGroupKey = ( remoteSiteId: number ) => `remote:${ remoteSiteId }`; + +export function createStudioWorkspaceId( { + productionSiteId, + localSiteId, + stagingSiteId, +}: { + productionSiteId?: number; + localSiteId?: string; + stagingSiteId?: number; +} ) { + if ( productionSiteId ) { + return `studio-workspace:wpcom:${ productionSiteId }`; + } + + if ( localSiteId ) { + return `studio-workspace:local:${ localSiteId }`; + } + + return `studio-workspace:wpcom:${ stagingSiteId }`; +} + +export function mergeWpcomSitesWithConnectedSites( + wpcomSites: SyncSite[] = [], + connectedSites: SyncSite[] = [] +) { + const connectedSitesById = new Map( connectedSites.map( ( site ) => [ site.id, site ] ) ); + const mergedSites = wpcomSites.map( ( site ) => { + const connectedSite = connectedSitesById.get( site.id ); + if ( ! connectedSite ) { + return site; + } + + return { + ...connectedSite, + ...site, + localSiteId: connectedSite.localSiteId || site.localSiteId, + productionSiteId: site.productionSiteId ?? connectedSite.productionSiteId, + stagingSiteIds: site.stagingSiteIds ?? connectedSite.stagingSiteIds, + syncSupport: + connectedSite.syncSupport === 'already-connected' + ? connectedSite.syncSupport + : site.syncSupport, + lastPullTimestamp: connectedSite.lastPullTimestamp ?? site.lastPullTimestamp, + lastPushTimestamp: connectedSite.lastPushTimestamp ?? site.lastPushTimestamp, + }; + } ); + const mergedSiteIds = new Set( mergedSites.map( ( site ) => site.id ) ); + + return [ ...mergedSites, ...connectedSites.filter( ( site ) => ! mergedSiteIds.has( site.id ) ) ]; +} + +function createRemoteRelationshipIndex( remoteSites: SyncSite[] ) { + const productionSiteIdByStagingSiteId = new Map< number, number >(); + + remoteSites.forEach( ( site ) => { + if ( site.isStaging ) { + return; + } + + site.stagingSiteIds?.forEach( ( stagingSiteId ) => { + if ( ! productionSiteIdByStagingSiteId.has( stagingSiteId ) ) { + productionSiteIdByStagingSiteId.set( stagingSiteId, site.id ); + } + } ); + } ); + + return productionSiteIdByStagingSiteId; +} + +function getKnownProductionSiteId( + site: SyncSite, + productionSiteIdByStagingSiteId: Map< number, number > +) { + if ( ! site.isStaging ) { + return site.id; + } + + return site.productionSiteId ?? productionSiteIdByStagingSiteId.get( site.id ); +} + +function getOrCreateGroup( groups: Map< string, WorkspaceGroup >, key: string ) { + const existingGroup = groups.get( key ); + if ( existingGroup ) { + return existingGroup; + } + + const group = { + remoteSites: [], + localSiteIds: new Set< string >(), + }; + groups.set( key, group ); + return group; +} + +function addRemoteSiteToGroup( group: WorkspaceGroup, site: SyncSite, productionSiteId?: number ) { + if ( ! group.remoteSites.some( ( remoteSite ) => remoteSite.id === site.id ) ) { + group.remoteSites.push( site ); + } + + if ( site.localSiteId ) { + group.localSiteIds.add( site.localSiteId ); + } + + if ( productionSiteId && ! group.productionSiteId ) { + group.productionSiteId = productionSiteId; + } +} + +function createWorkspaceSyncLinks( targets: StudioWorkspace[ 'targets' ] ): WorkspaceSyncLink[] { + const links: Array< [ WorkspaceTargetId, WorkspaceTargetId ] > = []; + + if ( targets.local && targets.production ) { + links.push( [ 'local', 'production' ] ); + } + + if ( targets.local && targets.staging ) { + links.push( [ 'local', 'staging' ] ); + } + + if ( targets.production && targets.staging ) { + links.push( [ 'production', 'staging' ] ); + } + + return links.map( ( [ source, target ] ) => ( { + id: `${ source }:${ target }`, + source, + target, + status: 'available', + } ) ); +} + +function createRemoteTarget( id: RemoteTarget[ 'id' ], site?: SyncSite ) { + if ( ! site ) { + return undefined; + } + + return { + id, + kind: 'remote' as const, + siteId: site.id, + site, + }; +} + +function getFirstLocalSite( + localSiteIds: Set< string >, + localSitesById: Map< string, SiteDetails >, + localSiteOrder: string[] +) { + const localSiteId = localSiteOrder.find( ( siteId ) => localSiteIds.has( siteId ) ); + if ( localSiteId ) { + return localSitesById.get( localSiteId ); + } + + return undefined; +} + +function createStudioWorkspace( + group: WorkspaceGroup, + localSitesById: Map< string, SiteDetails >, + localSiteOrder: string[] +): StudioWorkspace | undefined { + const localSite = getFirstLocalSite( group.localSiteIds, localSitesById, localSiteOrder ); + const productionSite = group.remoteSites + .filter( ( site ) => ! site.isStaging ) + .sort( ( a, b ) => a.name.localeCompare( b.name, undefined, { numeric: true } ) )[ 0 ]; + const stagingSite = group.remoteSites + .filter( ( site ) => site.isStaging ) + .sort( ( a, b ) => a.name.localeCompare( b.name, undefined, { numeric: true } ) )[ 0 ]; + + if ( ! localSite && ! productionSite && ! stagingSite ) { + return undefined; + } + + const targets: StudioWorkspace[ 'targets' ] = {}; + if ( localSite ) { + targets.local = { + id: 'local', + kind: 'local', + siteId: localSite.id, + site: localSite, + }; + } + const productionTarget = createRemoteTarget( 'production', productionSite ); + if ( productionTarget ) { + targets.production = productionTarget; + } + const stagingTarget = createRemoteTarget( 'staging', stagingSite ); + if ( stagingTarget ) { + targets.staging = stagingTarget; + } + const productionSiteId = + productionSite?.id ?? group.productionSiteId ?? stagingSite?.productionSiteId; + const workspace = { + id: createStudioWorkspaceId( { + productionSiteId, + localSiteId: localSite?.id, + stagingSiteId: stagingSite?.id, + } ), + name: localSite?.name ?? productionSite?.name ?? stagingSite?.name ?? '', + sortOrder: localSite?.sortOrder, + targets, + syncLinks: createWorkspaceSyncLinks( targets ), + activity: { + status: 'idle' as const, + }, + }; + + return workspace; +} + +export function buildStudioWorkspaces( { + localSites = [], + wpcomSites = [], + connectedSites = [], +}: BuildStudioWorkspacesInput ): StudioWorkspace[] { + const remoteSites = mergeWpcomSitesWithConnectedSites( wpcomSites, connectedSites ); + const localSitesById = new Map( localSites.map( ( site ) => [ site.id, site ] ) ); + const localSiteOrder = localSites.map( ( site ) => site.id ); + const productionSiteIdByStagingSiteId = createRemoteRelationshipIndex( remoteSites ); + const groups = new Map< string, WorkspaceGroup >(); + const groupedRemoteSiteIds = new Set< number >(); + const groupKeysByLocalSiteId = new Map< string, string >(); + + remoteSites.forEach( ( site ) => { + const productionSiteId = getKnownProductionSiteId( site, productionSiteIdByStagingSiteId ); + if ( ! productionSiteId ) { + return; + } + + const groupKey = createProductionGroupKey( productionSiteId ); + const group = getOrCreateGroup( groups, groupKey ); + addRemoteSiteToGroup( group, site, productionSiteId ); + groupedRemoteSiteIds.add( site.id ); + + if ( site.localSiteId ) { + groupKeysByLocalSiteId.set( site.localSiteId, groupKey ); + } + } ); + + remoteSites.forEach( ( site ) => { + if ( groupedRemoteSiteIds.has( site.id ) ) { + return; + } + + const groupKey = + ( site.localSiteId && groupKeysByLocalSiteId.get( site.localSiteId ) ) || + ( site.localSiteId + ? createLocalGroupKey( site.localSiteId ) + : createRemoteGroupKey( site.id ) ); + const group = getOrCreateGroup( groups, groupKey ); + addRemoteSiteToGroup( group, site ); + groupedRemoteSiteIds.add( site.id ); + + if ( site.localSiteId ) { + groupKeysByLocalSiteId.set( site.localSiteId, groupKey ); + } + } ); + + const localSiteIdsWithRemoteTargets = new Set< string >(); + groups.forEach( ( group ) => { + group.localSiteIds.forEach( ( siteId ) => localSiteIdsWithRemoteTargets.add( siteId ) ); + } ); + + localSites.forEach( ( site ) => { + if ( localSiteIdsWithRemoteTargets.has( site.id ) ) { + return; + } + + const group = getOrCreateGroup( groups, createLocalGroupKey( site.id ) ); + group.localSiteIds.add( site.id ); + } ); + + return sortSites( + Array.from( groups.values() ) + .map( ( group ) => createStudioWorkspace( group, localSitesById, localSiteOrder ) ) + .filter( ( workspace ): workspace is StudioWorkspace => Boolean( workspace ) ) + ); +} diff --git a/apps/studio/src/modules/workspaces/types.ts b/apps/studio/src/modules/workspaces/types.ts new file mode 100644 index 0000000000..9fac7c0b42 --- /dev/null +++ b/apps/studio/src/modules/workspaces/types.ts @@ -0,0 +1,49 @@ +import type { SyncSite } from '@studio/common/types/sync'; + +export type WorkspaceTargetId = 'local' | 'production' | 'staging'; + +export type LocalTarget = { + id: 'local'; + kind: 'local'; + siteId: string; + site: SiteDetails; +}; + +export type RemoteTargetId = Extract< WorkspaceTargetId, 'production' | 'staging' >; + +export type RemoteTarget = { + id: RemoteTargetId; + kind: 'remote'; + siteId: number; + site: SyncSite; +}; + +export type WorkspaceSyncLink = { + id: string; + source: WorkspaceTargetId; + target: WorkspaceTargetId; + status: 'available'; +}; + +export type WorkspaceActivity = { + status: 'idle'; +}; + +export type StudioWorkspace = { + id: string; + name: string; + sortOrder?: number; + targets: { + local?: LocalTarget; + production?: RemoteTarget; + staging?: RemoteTarget; + }; + syncLinks: WorkspaceSyncLink[]; + activity: WorkspaceActivity; +}; + +export type BuildStudioWorkspacesInput = { + localSites?: SiteDetails[]; + wpcomSites?: SyncSite[]; + connectedSites?: SyncSite[]; +}; diff --git a/apps/studio/src/stores/sync/wpcom-sites.ts b/apps/studio/src/stores/sync/wpcom-sites.ts index fb1a2c358a..f7cb1818ea 100644 --- a/apps/studio/src/stores/sync/wpcom-sites.ts +++ b/apps/studio/src/stores/sync/wpcom-sites.ts @@ -2,6 +2,7 @@ import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; import * as Sentry from '@sentry/electron/renderer'; import { getSyncSupport } from '@studio/common/lib/sync/sync-support'; import { + isStagingSiteResponse, transformSingleSiteResponse, transformSitesResponse, } from '@studio/common/lib/sync/transform-sites'; @@ -24,6 +25,7 @@ const SITE_FIELDS = [ 'jetpack', 'is_deleted', 'is_a8c', + 'is_wpcom_staging_site', 'hosting_provider_guess', 'environment_type', 'icon', @@ -57,17 +59,17 @@ export const wpcomSitesApi = createApi( { 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'; + // Single-site responses do not include the parent site's staging ID list. + const isStaging = isStagingSiteResponse( parsedSite ); const syncSupport = getSyncSupport( parsedSite, allConnectedSites.map( ( { id } ) => id ) ); - const syncSite = transformSingleSiteResponse( parsedSite, syncSupport, isStaging ); + const syncSite = transformSingleSiteResponse( parsedSite, syncSupport, isStaging, { + stagingSiteIds: isStaging ? undefined : parsedSite.options?.wpcom_staging_blog_ids, + } ); return { data: syncSite }; } catch ( error ) { @@ -166,11 +168,13 @@ export const wpcomSitesApi = createApi( { ); const parsed = sitesEndpointSiteSchema.parse( singleResponse ); const syncSupport = getSyncSupport( parsed, connectedIds ); - const isStaging = - parsed.environment_type === 'staging' || - parsed.environment_type === 'development'; + const isStaging = isStagingSiteResponse( parsed ); supplementalSites.push( - transformSingleSiteResponse( parsed, syncSupport, isStaging ) + transformSingleSiteResponse( parsed, syncSupport, isStaging, { + stagingSiteIds: isStaging + ? undefined + : parsed.options?.wpcom_staging_blog_ids, + } ) ); } catch ( error ) { const status = ( error as { status?: number } )?.status; diff --git a/tools/common/lib/sync/sync-api.ts b/tools/common/lib/sync/sync-api.ts index 1c946bd0bd..c47404077f 100644 --- a/tools/common/lib/sync/sync-api.ts +++ b/tools/common/lib/sync/sync-api.ts @@ -25,6 +25,7 @@ const SITE_FIELDS = [ 'jetpack', 'is_deleted', 'is_a8c', + 'is_wpcom_staging_site', 'hosting_provider_guess', 'environment_type', ].join( ',' ); diff --git a/tools/common/lib/sync/transform-sites.ts b/tools/common/lib/sync/transform-sites.ts index 3e69d677ab..e8ba1a4a6c 100644 --- a/tools/common/lib/sync/transform-sites.ts +++ b/tools/common/lib/sync/transform-sites.ts @@ -2,10 +2,20 @@ import { sitesEndpointSiteSchema } from '@studio/common/types/sync'; import { getSyncSupport, isPressableSite } from './sync-support'; import type { SitesEndpointSite, SyncSite, SyncSupport } from '@studio/common/types/sync'; +const STAGING_SITE_FEATURE = 'staging-sites'; + +export function isStagingSiteResponse( site: Pick< SitesEndpointSite, 'is_wpcom_staging_site' > ) { + return site.is_wpcom_staging_site === true; +} + export function transformSingleSiteResponse( site: SitesEndpointSite, syncSupport: SyncSupport, - isStaging: boolean + isStaging: boolean, + relationship?: { + productionSiteId?: number; + stagingSiteIds?: number[]; + } ): SyncSite { return { id: site.ID, @@ -13,7 +23,12 @@ export function transformSingleSiteResponse( name: site.name, url: site.URL, isStaging, + productionSiteId: relationship?.productionSiteId, + stagingSiteIds: relationship?.stagingSiteIds, isPressable: isPressableSite( site ), + isWpcomAtomic: site.is_wpcom_atomic, + canManageOptions: site.capabilities?.manage_options, + hasStagingSiteFeature: site.plan?.features.active.includes( STAGING_SITE_FEATURE ), environmentType: site.environment_type, syncSupport, lastPullTimestamp: null, @@ -51,9 +66,22 @@ export function transformSitesResponse( } }, [] ); - const allStagingSiteIds = validatedSites.flatMap( - ( site ) => site.options?.wpcom_staging_blog_ids ?? [] - ); + const stagingSiteIdsByProductionSiteId = new Map< number, number[] >(); + const productionSiteIdByStagingSiteId = new Map< number, number >(); + + validatedSites.forEach( ( site ) => { + const stagingSiteIds = site.options?.wpcom_staging_blog_ids ?? []; + if ( stagingSiteIds.length === 0 ) { + return; + } + + stagingSiteIdsByProductionSiteId.set( site.ID, stagingSiteIds ); + stagingSiteIds.forEach( ( stagingSiteId ) => { + if ( ! productionSiteIdByStagingSiteId.has( stagingSiteId ) ) { + productionSiteIdByStagingSiteId.set( stagingSiteId, site.ID ); + } + } ); + } ); return validatedSites .filter( ( site ) => ! site.is_a8c ) @@ -63,9 +91,14 @@ export function transformSitesResponse( ( connectedSiteIds.length > 0 && connectedSiteIds.some( ( id ) => id === site.ID ) ) ) .map( ( site ) => { - const isStaging = allStagingSiteIds.includes( site.ID ); + const productionSiteId = productionSiteIdByStagingSiteId.get( site.ID ); + const stagingSiteIds = stagingSiteIdsByProductionSiteId.get( site.ID ); + const isStaging = productionSiteId !== undefined || isStagingSiteResponse( site ); const syncSupport = getSyncSupport( site, connectedSiteIds ); - return transformSingleSiteResponse( site, syncSupport, isStaging ); + return transformSingleSiteResponse( site, syncSupport, isStaging, { + productionSiteId, + stagingSiteIds, + } ); } ); } diff --git a/tools/common/lib/tests/transform-sites.test.ts b/tools/common/lib/tests/transform-sites.test.ts new file mode 100644 index 0000000000..d18349f69c --- /dev/null +++ b/tools/common/lib/tests/transform-sites.test.ts @@ -0,0 +1,117 @@ +import { transformSitesResponse } from '../sync/transform-sites'; + +const createRawSite = ( { + ID, + name, + url, + isWpcomStagingSite = false, + wpcomStagingBlogIds = [], + environmentType = 'production', +}: { + ID: number; + name: string; + url?: string; + isWpcomStagingSite?: boolean; + wpcomStagingBlogIds?: number[]; + environmentType?: 'production' | 'staging' | 'development'; +} ) => ( { + ID, + is_wpcom_atomic: true, + name, + URL: url ?? `https://${ name.toLowerCase().replace( /\s+/g, '-' ) }.example`, + jetpack: false, + is_deleted: false, + is_wpcom_staging_site: isWpcomStagingSite, + environment_type: environmentType, + options: { + created_at: '2026-01-01T00:00:00+00:00', + wpcom_staging_blog_ids: wpcomStagingBlogIds, + software_version: '6.9.4', + }, + capabilities: { + manage_options: true, + }, + plan: { + features: { + active: [ 'staging-sites' ], + }, + product_id: 1, + product_name_short: 'Business', + product_slug: 'business', + }, +} ); + +describe( 'transformSitesResponse', () => { + it( 'preserves WordPress.com production and staging relationships', () => { + const sites = transformSitesResponse( [ + createRawSite( { + ID: 101, + name: 'Auro Atelier', + wpcomStagingBlogIds: [ 202 ], + } ), + createRawSite( { + ID: 202, + name: 'Auro Atelier Staging', + isWpcomStagingSite: true, + environmentType: 'staging', + } ), + ] ); + + expect( sites ).toHaveLength( 2 ); + expect( sites[ 0 ] ).toEqual( + expect.objectContaining( { + id: 101, + isStaging: false, + stagingSiteIds: [ 202 ], + productionSiteId: undefined, + isWpcomAtomic: true, + canManageOptions: true, + hasStagingSiteFeature: true, + } ) + ); + expect( sites[ 1 ] ).toEqual( + expect.objectContaining( { + id: 202, + isStaging: true, + productionSiteId: 101, + stagingSiteIds: undefined, + } ) + ); + } ); + + it( 'uses the explicit WordPress.com staging site flag', () => { + const sites = transformSitesResponse( [ + createRawSite( { + ID: 101, + name: 'My Store', + url: 'https://staging-1234-mystore.wpcomstaging.com', + isWpcomStagingSite: true, + } ), + ] ); + + expect( sites[ 0 ] ).toEqual( + expect.objectContaining( { + id: 101, + isStaging: true, + } ) + ); + } ); + + it( 'does not infer staging from a WordPress.com staging hostname', () => { + const sites = transformSitesResponse( [ + createRawSite( { + ID: 101, + name: 'My Store', + url: 'https://store.wpcomstaging.com', + isWpcomStagingSite: false, + } ), + ] ); + + expect( sites[ 0 ] ).toEqual( + expect.objectContaining( { + id: 101, + isStaging: false, + } ) + ); + } ); +} ); diff --git a/tools/common/types/sync.ts b/tools/common/types/sync.ts index b1c8ef77b0..7b14951332 100644 --- a/tools/common/types/sync.ts +++ b/tools/common/types/sync.ts @@ -9,6 +9,7 @@ export const sitesEndpointSiteSchema = z.object( { jetpack: z.boolean().optional(), is_deleted: z.boolean(), hosting_provider_guess: z.string().optional(), + is_wpcom_staging_site: z.boolean().optional(), environment_type: z .enum( [ 'production', 'staging', 'development', 'sandbox', 'local' ] ) .nullable() @@ -78,7 +79,12 @@ export const syncSiteSchema = z.object( { name: z.string(), url: z.string(), isStaging: z.boolean(), + productionSiteId: z.number().optional(), + stagingSiteIds: z.array( z.number() ).optional(), isPressable: z.boolean(), + isWpcomAtomic: z.boolean().optional(), + canManageOptions: z.boolean().optional(), + hasStagingSiteFeature: z.boolean().optional(), environmentType: z.string().nullable().optional(), syncSupport: z.enum( syncSupportValues ), lastPullTimestamp: z.string().nullable(), From 95e90abe0e341b98cb3ed5bf5c6554be92463441 Mon Sep 17 00:00:00 2001 From: dereksmart Date: Fri, 15 May 2026 17:01:08 -0400 Subject: [PATCH 2/7] Add workspace-backed sidebar path What changed. Adds `useSidebarWorkspaces()` to gather local Studio sites, connected WordPress.com metadata, and fetched WP.com sites into one canonical workspace list for the sidebar when `enableWorkspaces` is on. Updates `SiteMenu` so the flag-on path renders `sidebarWorkspaces.map(...)` instead of separate local and remote lists. Linked local plus production/staging workspaces render once, remote-only workspaces appear in the same sorted list, and local start/stop remains attached only to the Local target. Adds workspace sidebar rows, accessible target indicators, and minimal per-workspace target selection state. Selecting Local still calls the existing `setSelectedSiteId()` path, while selecting Production or Staging updates workspace sidebar state only in this slice. What stayed unchanged / what is intentionally deferred. The flag-off sidebar path stays on the existing `sites.map(...)` behavior, including drag reorder and local start/stop controls. Workspace rows disable drag reorder while the flag is on because remote-only rows do not yet have a persistence model. Remote content routing, shared shell UI, Dolly, preview, sync creation, and target creation remain deferred to later slices. Tradeoffs and risks. This slice creates a temporary split: the sidebar can select remote targets, but the content area still uses the old local-site surface until the shared shell lands. That is intentional to keep the PR reviewable and to avoid mixing list composition with content routing. The main risk is duplicate or inaccessible sidebar rows; focused tests cover duplicate prevention and translated target labels instead of letter-only indicators. Verification. Verified with `npx eslint --fix `, `npm test -- apps/studio/src/components/tests/main-sidebar.test.tsx apps/studio/src/modules/workspaces/lib/build-studio-workspaces.test.ts`, `npm run typecheck`, and `git diff --check`. --- apps/studio/src/components/main-sidebar.tsx | 4 +- apps/studio/src/components/site-menu.tsx | 99 ++++- .../components/tests/main-sidebar.test.tsx | 341 ++++++++++++++++-- .../components/workspace-sidebar-row.tsx | 49 +++ .../workspace-target-indicators.tsx | 121 +++++++ .../hooks/use-sidebar-workspaces.ts | 92 +++++ .../hooks/use-workspace-target-selection.ts | 78 ++++ apps/studio/src/modules/workspaces/index.ts | 7 + .../workspaces/lib/target-selection.ts | 32 ++ 9 files changed, 781 insertions(+), 42 deletions(-) create mode 100644 apps/studio/src/modules/workspaces/components/workspace-sidebar-row.tsx create mode 100644 apps/studio/src/modules/workspaces/components/workspace-target-indicators.tsx create mode 100644 apps/studio/src/modules/workspaces/hooks/use-sidebar-workspaces.ts create mode 100644 apps/studio/src/modules/workspaces/hooks/use-workspace-target-selection.ts create mode 100644 apps/studio/src/modules/workspaces/lib/target-selection.ts diff --git a/apps/studio/src/components/main-sidebar.tsx b/apps/studio/src/components/main-sidebar.tsx index 235e8b76f4..e7488dd37f 100644 --- a/apps/studio/src/components/main-sidebar.tsx +++ b/apps/studio/src/components/main-sidebar.tsx @@ -1,6 +1,7 @@ import { __ } from '@wordpress/i18n'; import { RunningSites } from 'src/components/running-sites'; import SiteMenu from 'src/components/site-menu'; +import { useFeatureFlags } from 'src/hooks/use-feature-flags'; import { useSiteDetails } from 'src/hooks/use-site-details'; import { isMac } from 'src/lib/app-globals'; import { cx } from 'src/lib/cx'; @@ -13,6 +14,7 @@ interface MainSidebarProps { export default function MainSidebar( { className, style }: MainSidebarProps ) { const { sites: localSites } = useSiteDetails(); + const { enableWorkspaces } = useFeatureFlags(); return (
- { ! localSites.length ? ( + { ! localSites.length && ! enableWorkspaces ? (
{ __( 'Your sites will show up here once you create them' ) }
diff --git a/apps/studio/src/components/site-menu.tsx b/apps/studio/src/components/site-menu.tsx index 08f79f7b06..1a6a5bbffe 100644 --- a/apps/studio/src/components/site-menu.tsx +++ b/apps/studio/src/components/site-menu.tsx @@ -14,9 +14,12 @@ import { cx } from 'src/lib/cx'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { supportedEditorConfig } from 'src/modules/user-settings/lib/editor'; import { getTerminalName } from 'src/modules/user-settings/lib/terminal'; +import { useSidebarWorkspaces, useWorkspaceTargetSelection } from 'src/modules/workspaces'; +import { WorkspaceSidebarRow } from 'src/modules/workspaces/components/workspace-sidebar-row'; import { useRootSelector } from 'src/stores'; import { useGetUserEditorQuery, useGetUserTerminalQuery } from 'src/stores/installed-apps-api'; import { syncOperationsSelectors } from 'src/stores/sync'; +import type { StudioWorkspace, WorkspaceTargetId } from 'src/modules/workspaces'; interface SiteMenuProps { className?: string; @@ -257,9 +260,33 @@ export default function SiteMenu( { className }: SiteMenuProps ) { const { setSelectedTab } = useContentTabs(); const { handleDeleteSite } = useDeleteSite(); const { data: editor } = useGetUserEditorQuery(); + const { + enableWorkspaces, + sidebarWorkspaces, + isLoading: isLoadingWorkspaces, + } = useSidebarWorkspaces(); + const { selectedWorkspaceId, getSelectedTargetId, selectWorkspaceTarget } = + useWorkspaceTargetSelection( sidebarWorkspaces ); const [ draggedIndex, setDraggedIndex ] = useState< number | null >( null ); const [ dragOverIndex, setDragOverIndex ] = useState< number | null >( null ); + const handleSelectWorkspaceTarget = ( + workspace: StudioWorkspace, + targetId: WorkspaceTargetId + ) => { + selectWorkspaceTarget( workspace.id, targetId ); + if ( targetId === 'local' && workspace.targets.local ) { + setSelectedSiteId( workspace.targets.local.siteId ); + } + }; + + const isWorkspaceSelected = ( + workspace: StudioWorkspace, + selectedTargetId?: WorkspaceTargetId + ) => + selectedWorkspaceId === workspace.id || + ( selectedTargetId === 'local' && workspace.targets.local?.siteId === selectedSite?.id ); + const handleDragStart = ( e: React.DragEvent, index: number ) => { setDraggedIndex( index ); e.dataTransfer.effectAllowed = 'move'; @@ -392,24 +419,60 @@ export default function SiteMenu( { className }: SiteMenuProps ) { ) } >
    - { sites.map( ( site, index ) => ( - - ) ) } - { /* Drop zone for dragging to bottom of list */ } -
  • handleDragOver( e, sites.length ) } - onDrop={ ( e ) => handleDrop( e, sites.length ) } - /> + { enableWorkspaces ? ( + <> + { sidebarWorkspaces.map( ( workspace ) => { + const selectedTargetId = getSelectedTargetId( workspace ); + return ( + + ) : undefined + } + onSelectTarget={ ( targetId ) => + handleSelectWorkspaceTarget( workspace, targetId ) + } + /> + ); + } ) } + { isLoadingWorkspaces && ( +
  • + { __( 'Loading...' ) } +
  • + ) } + + ) : ( + <> + { sites.map( ( site, index ) => ( + + ) ) } + { /* Drop zone for dragging to bottom of list */ } +
  • handleDragOver( e, sites.length ) } + onDrop={ ( e ) => handleDrop( e, sites.length ) } + /> + + ) }
); diff --git a/apps/studio/src/components/tests/main-sidebar.test.tsx b/apps/studio/src/components/tests/main-sidebar.test.tsx index 4b525ef100..96d69301ed 100644 --- a/apps/studio/src/components/tests/main-sidebar.test.tsx +++ b/apps/studio/src/components/tests/main-sidebar.test.tsx @@ -1,4 +1,4 @@ -import { render, act, screen } from '@testing-library/react'; +import { render, act, screen, waitFor } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { Provider } from 'react-redux'; import { vi } from 'vitest'; @@ -6,8 +6,30 @@ import MainSidebar from 'src/components/main-sidebar'; import { useAuth } from 'src/hooks/use-auth'; import { ContentTabsProvider } from 'src/hooks/use-content-tabs'; import { store } from 'src/stores'; +import type { SyncSite } from '@studio/common/types/sync'; + +const featureFlagsMock = vi.hoisted( () => ( { + enableBlueprints: true, + enableStudioCodeUi: false, + enableWorkspaces: false, +} ) ); +const ipcApiMock = vi.hoisted( () => ( { + getConnectedWpcomSites: vi.fn().mockResolvedValue( [] ), + showOpenFolderDialog: vi.fn(), + generateProposedSitePath: vi.fn(), + openURL: vi.fn(), + getAllCustomDomains: vi.fn().mockResolvedValue( [] ), + getUserEditor: vi.fn().mockResolvedValue( 'cursor' ), + getUserTerminal: vi.fn().mockResolvedValue( 'terminal' ), + setWindowControlVisibility: vi.fn(), + setupAppMenu: vi.fn(), +} ) ); +const useGetWpComSitesQueryMock = vi.hoisted( () => vi.fn() ); vi.mock( 'src/hooks/use-auth' ); +vi.mock( 'src/hooks/use-feature-flags', () => ( { + useFeatureFlags: () => featureFlagsMock, +} ) ); vi.mock( 'src/stores/wordpress-versions-api', () => ( { wordpressVersionsApi: { @@ -45,44 +67,62 @@ vi.mock( 'src/stores/wpcom-api', async () => { vi.mock( 'src/lib/get-ipc-api', () => ( { __esModule: true, default: vi.fn(), - getIpcApi: () => ( { - getConnectedWpcomSites: vi.fn().mockResolvedValue( [] ), - showOpenFolderDialog: vi.fn(), - generateProposedSitePath: vi.fn(), - openURL: vi.fn(), - getAllCustomDomains: vi.fn().mockResolvedValue( [] ), - getUserEditor: vi.fn().mockResolvedValue( 'cursor' ), - getUserTerminal: vi.fn().mockResolvedValue( 'terminal' ), - setWindowControlVisibility: vi.fn(), - setupAppMenu: vi.fn(), - } ), + getIpcApi: () => ipcApiMock, } ) ); -const site2 = { +vi.mock( 'src/stores/sync/wpcom-sites', async () => { + const actual = await vi.importActual< typeof import('src/stores/sync/wpcom-sites') >( + 'src/stores/sync/wpcom-sites' + ); + return { + ...actual, + useGetWpComSitesQuery: useGetWpComSitesQueryMock, + }; +} ); + +const createLocalSite = ( overrides: Partial< SiteDetails > = {} ): SiteDetails => + ( { + name: 'test-1', + path: '/fake/test-1', + running: false, + id: '0e9e237b-335a-43fa-b439-9b078a618512', + port: 8881, + phpVersion: '8.4', + ...overrides, + } ) as SiteDetails; + +const createSyncSite = ( overrides: Partial< SyncSite > = {} ): SyncSite => ( { + id: 101, + localSiteId: '', + name: 'Business Plan', + url: 'https://business.example', + isStaging: false, + isPressable: false, + syncSupport: 'syncable', + lastPullTimestamp: null, + lastPushTimestamp: null, + ...overrides, +} ); + +const site2 = createLocalSite( { name: 'test-2', path: '/fake/test-2', running: false, id: 'da1dad4b-37d5-41d2-a77b-26d5e0649ec3', port: 8882, -}; +} ); const siteDetailsMocked = { selectedSite: site2, sites: [ - { - name: 'test-1', - path: '/fake/test-1', - running: false, - id: '0e9e237b-335a-43fa-b439-9b078a618512', - port: 8881, - }, + createLocalSite(), site2, - { + createLocalSite( { name: 'test-3', path: '/fake/test-3', running: true, id: '0e9e237b-335a-43fa-b439-9b078a613333', port: 8883, - }, + } ), ], loadingServer: { [ site2.id ]: false, @@ -98,6 +138,47 @@ vi.mock( 'src/hooks/use-site-details', () => ( { useSiteDetails: () => ( { ...siteDetailsMocked } ), } ) ); +const defaultLocalSites = () => [ + createLocalSite(), + site2, + createLocalSite( { + name: 'test-3', + path: '/fake/test-3', + running: true, + id: '0e9e237b-335a-43fa-b439-9b078a613333', + port: 8883, + } ), +]; + +const mockWpcomSitesQuery = ( sites: SyncSite[] = [] ) => { + useGetWpComSitesQueryMock.mockReturnValue( { + data: { sites, total: sites.length, page: 1, perPage: 100 }, + isLoading: false, + isFetching: false, + } ); +}; + +const enableWorkspaceSidebar = ( { + localSites = [], + wpcomSites = [], + connectedSites = [], +}: { + localSites?: SiteDetails[]; + wpcomSites?: SyncSite[]; + connectedSites?: SyncSite[]; +} ) => { + featureFlagsMock.enableWorkspaces = true; + siteDetailsMocked.sites = localSites; + siteDetailsMocked.selectedSite = localSites[ 0 ] ?? null; + vi.mocked( useAuth, { partial: true } ).mockReturnValue( { + isAuthenticated: true, + user: { id: 123, email: 'user@example.com', displayName: 'User' }, + client: {} as never, + } ); + ipcApiMock.getConnectedWpcomSites.mockResolvedValue( connectedSites ); + mockWpcomSitesQuery( wpcomSites ); +}; + const renderWithProvider = ( children: React.ReactElement ) => { return render( @@ -109,6 +190,13 @@ const renderWithProvider = ( children: React.ReactElement ) => { describe( 'MainSidebar Footer', () => { beforeEach( () => { vi.clearAllMocks(); + localStorage.clear(); + featureFlagsMock.enableWorkspaces = false; + siteDetailsMocked.sites = defaultLocalSites(); + siteDetailsMocked.selectedSite = site2; + vi.mocked( useAuth, { partial: true } ).mockReturnValue( { isAuthenticated: false } ); + ipcApiMock.getConnectedWpcomSites.mockResolvedValue( [] ); + mockWpcomSitesQuery(); } ); it( 'Has add site button', async () => { vi.mocked( useAuth, { partial: true } ).mockReturnValue( { isAuthenticated: false } ); @@ -137,6 +225,17 @@ describe( 'MainSidebar Footer', () => { } ); describe( 'MainSidebar Site Menu', () => { + beforeEach( () => { + vi.clearAllMocks(); + localStorage.clear(); + featureFlagsMock.enableWorkspaces = false; + siteDetailsMocked.sites = defaultLocalSites(); + siteDetailsMocked.selectedSite = site2; + vi.mocked( useAuth, { partial: true } ).mockReturnValue( { isAuthenticated: false } ); + ipcApiMock.getConnectedWpcomSites.mockResolvedValue( [] ); + mockWpcomSitesQuery(); + } ); + it( 'renders the list of sites', async () => { await act( async () => renderWithProvider( ) ); expect( screen.getByRole( 'button', { name: 'test-1' } ) ).toBeVisible(); @@ -172,3 +271,199 @@ describe( 'MainSidebar Site Menu', () => { ); } ); } ); + +describe( 'MainSidebar Workspace Site Menu', () => { + beforeEach( () => { + vi.clearAllMocks(); + localStorage.clear(); + ipcApiMock.getConnectedWpcomSites.mockResolvedValue( [] ); + mockWpcomSitesQuery(); + } ); + + it( 'renders a local-only workspace', async () => { + const localSite = createLocalSite( { id: 'local-only', name: 'Local Only' } ); + enableWorkspaceSidebar( { localSites: [ localSite ] } ); + + await act( async () => renderWithProvider( ) ); + + expect( screen.getByRole( 'button', { name: 'Local Only' } ) ).toBeVisible(); + expect( + screen.getByRole( 'button', { name: 'Select Local target: Local Only is stopped' } ) + ).toBeVisible(); + expect( screen.getByRole( 'button', { name: 'start Local Only site' } ) ).toBeVisible(); + } ); + + it( 'renders local and production targets as one workspace', async () => { + const localSite = createLocalSite( { id: 'business-local', name: 'Business Plan' } ); + const productionSite = createSyncSite( { + id: 101, + localSiteId: localSite.id, + name: 'Business Plan', + syncSupport: 'already-connected', + } ); + enableWorkspaceSidebar( { + localSites: [ localSite ], + wpcomSites: [ productionSite ], + connectedSites: [ productionSite ], + } ); + + await act( async () => renderWithProvider( ) ); + await screen.findByRole( 'button', { + name: 'Select Production target: https://business.example', + } ); + + expect( screen.getAllByRole( 'button', { name: 'Business Plan' } ) ).toHaveLength( 1 ); + expect( + screen.getByRole( 'button', { name: 'Select Local target: Business Plan is stopped' } ) + ).toBeVisible(); + } ); + + it( 'renders production and staging targets as one remote-only workspace', async () => { + const productionSite = createSyncSite( { + id: 101, + name: 'Remote Store', + url: 'https://remote.example', + stagingSiteIds: [ 202 ], + } ); + const stagingSite = createSyncSite( { + id: 202, + name: 'Remote Store Staging', + url: 'https://remote-staging.example', + isStaging: true, + productionSiteId: 101, + } ); + enableWorkspaceSidebar( { + wpcomSites: [ productionSite, stagingSite ], + } ); + + await act( async () => renderWithProvider( ) ); + + expect( await screen.findByRole( 'button', { name: 'Remote Store' } ) ).toBeVisible(); + expect( + screen.getByRole( 'button', { name: 'Select Production target: https://remote.example' } ) + ).toBeVisible(); + expect( + screen.getByRole( 'button', { + name: 'Select Staging target: https://remote-staging.example', + } ) + ).toBeVisible(); + expect( + screen.queryByRole( 'button', { name: 'Remote Store Staging' } ) + ).not.toBeInTheDocument(); + } ); + + it( 'renders local, production, and staging targets as one workspace', async () => { + const localSite = createLocalSite( { id: 'full-local', name: 'Full Workspace' } ); + const productionSite = createSyncSite( { + id: 101, + localSiteId: localSite.id, + name: 'Full Workspace', + stagingSiteIds: [ 202 ], + syncSupport: 'already-connected', + } ); + const stagingSite = createSyncSite( { + id: 202, + localSiteId: localSite.id, + name: 'Full Workspace Staging', + url: 'https://full-staging.example', + isStaging: true, + productionSiteId: 101, + syncSupport: 'already-connected', + } ); + enableWorkspaceSidebar( { + localSites: [ localSite ], + wpcomSites: [ productionSite, stagingSite ], + connectedSites: [ productionSite, stagingSite ], + } ); + + await act( async () => renderWithProvider( ) ); + await screen.findByRole( 'button', { + name: 'Select Staging target: https://full-staging.example', + } ); + + expect( screen.getAllByRole( 'button', { name: 'Full Workspace' } ) ).toHaveLength( 1 ); + expect( + screen.getByRole( 'group', { name: 'Workspace targets: Production, Staging, Local' } ) + ).toBeVisible(); + } ); + + it( 'renders production-only remote workspaces in the workspace list', async () => { + const productionSite = createSyncSite( { + id: 303, + name: 'Remote Only', + url: 'https://remote-only.example', + } ); + enableWorkspaceSidebar( { + wpcomSites: [ productionSite ], + } ); + + await act( async () => renderWithProvider( ) ); + + expect( await screen.findByRole( 'button', { name: 'Remote Only' } ) ).toBeVisible(); + expect( + screen.getByRole( 'button', { + name: 'Select Production target: https://remote-only.example', + } ) + ).toBeVisible(); + } ); + + it( 'does not duplicate local-backed workspaces when connected metadata overlaps WP.com data', async () => { + const localSite = createLocalSite( { id: 'overlap-local', name: 'Overlap Site' } ); + const productionSite = createSyncSite( { + id: 101, + localSiteId: localSite.id, + name: 'Overlap Site', + syncSupport: 'already-connected', + } ); + enableWorkspaceSidebar( { + localSites: [ localSite ], + wpcomSites: [ productionSite ], + connectedSites: [ productionSite ], + } ); + + await act( async () => renderWithProvider( ) ); + await screen.findByRole( 'button', { + name: 'Select Production target: https://business.example', + } ); + + expect( screen.getAllByRole( 'button', { name: 'Overlap Site' } ) ).toHaveLength( 1 ); + } ); + + it( 'uses accessible target labels instead of letter-only controls', async () => { + const localSite = createLocalSite( { id: 'labels-local', name: 'Label Site' } ); + const productionSite = createSyncSite( { + id: 101, + localSiteId: localSite.id, + name: 'Label Site', + stagingSiteIds: [ 202 ], + } ); + const stagingSite = createSyncSite( { + id: 202, + localSiteId: localSite.id, + name: 'Label Site Staging', + url: 'https://label-staging.example', + isStaging: true, + productionSiteId: 101, + } ); + enableWorkspaceSidebar( { + localSites: [ localSite ], + wpcomSites: [ productionSite, stagingSite ], + } ); + + await act( async () => renderWithProvider( ) ); + await waitFor( () => + expect( + screen.getByRole( 'group', { + name: 'Workspace targets: Production, Staging, Local', + } ) + ).toBeVisible() + ); + + expect( screen.getByRole( 'button', { name: /Select Production target:/ } ) ).toBeVisible(); + expect( screen.getByRole( 'button', { name: /Select Staging target:/ } ) ).toBeVisible(); + expect( screen.getByRole( 'button', { name: /Select Local target:/ } ) ).toBeVisible(); + expect( screen.queryByRole( 'button', { name: 'P' } ) ).not.toBeInTheDocument(); + expect( screen.queryByRole( 'button', { name: 'S' } ) ).not.toBeInTheDocument(); + expect( screen.queryByRole( 'button', { name: 'L' } ) ).not.toBeInTheDocument(); + } ); +} ); diff --git a/apps/studio/src/modules/workspaces/components/workspace-sidebar-row.tsx b/apps/studio/src/modules/workspaces/components/workspace-sidebar-row.tsx new file mode 100644 index 0000000000..cbdbd09180 --- /dev/null +++ b/apps/studio/src/modules/workspaces/components/workspace-sidebar-row.tsx @@ -0,0 +1,49 @@ +import { isMac } from 'src/lib/app-globals'; +import { cx } from 'src/lib/cx'; +import { WorkspaceTargetIndicators } from 'src/modules/workspaces/components/workspace-target-indicators'; +import type { ReactNode } from 'react'; +import type { StudioWorkspace, WorkspaceTargetId } from 'src/modules/workspaces/types'; + +type WorkspaceSidebarRowProps = { + workspace: StudioWorkspace; + selectedTargetId?: WorkspaceTargetId; + isSelected: boolean; + localRunControl?: ReactNode; + onSelectTarget: ( targetId: WorkspaceTargetId ) => void; +}; + +export function WorkspaceSidebarRow( { + workspace, + selectedTargetId, + isSelected, + localRunControl, + onSelectTarget, +}: WorkspaceSidebarRowProps ) { + return ( +
  • + + + { localRunControl } +
  • + ); +} diff --git a/apps/studio/src/modules/workspaces/components/workspace-target-indicators.tsx b/apps/studio/src/modules/workspaces/components/workspace-target-indicators.tsx new file mode 100644 index 0000000000..4eb28ec41e --- /dev/null +++ b/apps/studio/src/modules/workspaces/components/workspace-target-indicators.tsx @@ -0,0 +1,121 @@ +import { __, sprintf } from '@wordpress/i18n'; +import { Tooltip } from 'src/components/tooltip'; +import { cx } from 'src/lib/cx'; +import type { StudioWorkspace, WorkspaceTargetId } from 'src/modules/workspaces/types'; + +type WorkspaceTargetIndicatorsProps = { + workspace: StudioWorkspace; + selectedTargetId?: WorkspaceTargetId; + onSelectTarget: ( targetId: WorkspaceTargetId ) => void; +}; + +type Indicator = { + targetId: WorkspaceTargetId; + label: string; + ariaLabel: string; + dotClassName: string; + isSelected: boolean; +}; + +export function WorkspaceTargetIndicators( { + workspace, + selectedTargetId, + onSelectTarget, +}: WorkspaceTargetIndicatorsProps ) { + const indicators: Indicator[] = []; + + if ( workspace.targets.production ) { + indicators.push( { + targetId: 'production', + label: __( 'Production' ), + ariaLabel: sprintf( + // translators: %s is the production site URL. + __( 'Select Production target: %s' ), + workspace.targets.production.site.url + ), + dotClassName: 'bg-frame-theme', + isSelected: selectedTargetId === 'production', + } ); + } + + if ( workspace.targets.staging ) { + indicators.push( { + targetId: 'staging', + label: __( 'Staging' ), + ariaLabel: sprintf( + // translators: %s is the staging site URL. + __( 'Select Staging target: %s' ), + workspace.targets.staging.site.url + ), + dotClassName: 'bg-white', + isSelected: selectedTargetId === 'staging', + } ); + } + + if ( workspace.targets.local ) { + const localSite = workspace.targets.local.site; + indicators.push( { + targetId: 'local', + label: __( 'Local' ), + ariaLabel: localSite.running + ? sprintf( + // translators: %s is the local site name. + __( 'Select Local target: %s is running' ), + localSite.name + ) + : sprintf( + // translators: %s is the local site name. + __( 'Select Local target: %s is stopped' ), + localSite.name + ), + dotClassName: localSite.running ? 'bg-a8c-green-20' : 'bg-a8c-gray-500', + isSelected: selectedTargetId === 'local', + } ); + } + + if ( indicators.length === 0 ) { + return null; + } + + const groupLabel = sprintf( + // translators: %s is a comma-separated list of workspace targets, such as "Production, Staging, Local". + __( 'Workspace targets: %s' ), + indicators.map( ( indicator ) => indicator.label ).join( ', ' ) + ); + + return ( +
    + { indicators.map( ( indicator ) => ( + + + + ) ) } +
    + ); +} diff --git a/apps/studio/src/modules/workspaces/hooks/use-sidebar-workspaces.ts b/apps/studio/src/modules/workspaces/hooks/use-sidebar-workspaces.ts new file mode 100644 index 0000000000..87d302c444 --- /dev/null +++ b/apps/studio/src/modules/workspaces/hooks/use-sidebar-workspaces.ts @@ -0,0 +1,92 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useAuth } from 'src/hooks/use-auth'; +import { useFeatureFlags } from 'src/hooks/use-feature-flags'; +import { useSiteDetails } from 'src/hooks/use-site-details'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import { buildStudioWorkspaces } from 'src/modules/workspaces/lib/build-studio-workspaces'; +import { useGetWpComSitesQuery } from 'src/stores/sync/wpcom-sites'; +import type { SyncSite } from '@studio/common/types/sync'; + +export function useSidebarWorkspaces() { + const { sites: localSites } = useSiteDetails(); + const { isAuthenticated, user } = useAuth(); + const { enableWorkspaces } = useFeatureFlags(); + const [ connectedSites, setConnectedSites ] = useState< SyncSite[] >( [] ); + const [ isLoadingConnectedSites, setIsLoadingConnectedSites ] = useState( false ); + const connectedSiteIds = useMemo( + () => connectedSites.map( ( site ) => site.id ), + [ connectedSites ] + ); + const shouldLoadRemoteSites = enableWorkspaces && isAuthenticated; + const { + data: wpcomSitesData, + isFetching: isFetchingWpcomSites, + isLoading: isLoadingWpcomSites, + } = useGetWpComSitesQuery( + { + connectedSiteIds, + userId: user?.id, + perPage: 100, + }, + { skip: ! shouldLoadRemoteSites } + ); + + useEffect( () => { + if ( ! shouldLoadRemoteSites ) { + setConnectedSites( [] ); + setIsLoadingConnectedSites( false ); + return; + } + + let isCurrent = true; + setIsLoadingConnectedSites( true ); + getIpcApi() + .getConnectedWpcomSites() + .then( ( sites ) => { + if ( isCurrent ) { + setConnectedSites( sites ); + } + } ) + .catch( ( error ) => { + console.error( 'Failed to load connected WordPress.com sites:', error ); + if ( isCurrent ) { + setConnectedSites( [] ); + } + } ) + .finally( () => { + if ( isCurrent ) { + setIsLoadingConnectedSites( false ); + } + } ); + + return () => { + isCurrent = false; + }; + }, [ shouldLoadRemoteSites ] ); + + const wpcomSites = useMemo( + () => ( shouldLoadRemoteSites ? wpcomSitesData?.sites ?? [] : [] ), + [ shouldLoadRemoteSites, wpcomSitesData?.sites ] + ); + const sidebarWorkspaces = useMemo( + () => + enableWorkspaces + ? buildStudioWorkspaces( { + localSites, + wpcomSites, + connectedSites, + } ) + : [], + [ connectedSites, enableWorkspaces, localSites, wpcomSites ] + ); + + return { + enableWorkspaces, + sidebarWorkspaces, + wpcomSites, + connectedSites, + isLoading: + shouldLoadRemoteSites && + ( isLoadingConnectedSites || isLoadingWpcomSites || isFetchingWpcomSites ), + }; +} diff --git a/apps/studio/src/modules/workspaces/hooks/use-workspace-target-selection.ts b/apps/studio/src/modules/workspaces/hooks/use-workspace-target-selection.ts new file mode 100644 index 0000000000..1385ca8bdc --- /dev/null +++ b/apps/studio/src/modules/workspaces/hooks/use-workspace-target-selection.ts @@ -0,0 +1,78 @@ +import { useCallback, useMemo, useState } from 'react'; +import { + getDefaultWorkspaceTargetId, + getWorkspaceTargetStorageKey, + isWorkspaceTargetAvailable, +} from 'src/modules/workspaces/lib/target-selection'; +import type { StudioWorkspace, WorkspaceTargetId } from 'src/modules/workspaces/types'; + +function readSavedTargetId( workspace: StudioWorkspace ): WorkspaceTargetId | undefined { + try { + const savedTargetId = localStorage.getItem( getWorkspaceTargetStorageKey( workspace.id ) ); + if ( + savedTargetId === 'local' || + savedTargetId === 'production' || + savedTargetId === 'staging' + ) { + return savedTargetId; + } + } catch { + return undefined; + } + + return undefined; +} + +function writeSavedTargetId( workspaceId: string, targetId: WorkspaceTargetId ) { + try { + localStorage.setItem( getWorkspaceTargetStorageKey( workspaceId ), targetId ); + } catch { + // Ignore storage failures; selection still works for the current render. + } +} + +export function useWorkspaceTargetSelection( workspaces: StudioWorkspace[] ) { + const [ selectedWorkspaceId, setSelectedWorkspaceId ] = useState< string | null >( null ); + const [ selectedTargets, setSelectedTargets ] = useState< Record< string, WorkspaceTargetId > >( + {} + ); + const workspacesById = useMemo( + () => new Map( workspaces.map( ( workspace ) => [ workspace.id, workspace ] ) ), + [ workspaces ] + ); + + const getSelectedTargetId = useCallback( + ( workspace: StudioWorkspace ) => { + const selectedTargetId = selectedTargets[ workspace.id ] ?? readSavedTargetId( workspace ); + if ( selectedTargetId && isWorkspaceTargetAvailable( workspace, selectedTargetId ) ) { + return selectedTargetId; + } + + return getDefaultWorkspaceTargetId( workspace ); + }, + [ selectedTargets ] + ); + + const selectWorkspaceTarget = useCallback( + ( workspaceId: string, targetId: WorkspaceTargetId ) => { + const workspace = workspacesById.get( workspaceId ); + if ( ! workspace || ! isWorkspaceTargetAvailable( workspace, targetId ) ) { + return; + } + + setSelectedWorkspaceId( workspaceId ); + setSelectedTargets( ( current ) => ( { + ...current, + [ workspaceId ]: targetId, + } ) ); + writeSavedTargetId( workspaceId, targetId ); + }, + [ workspacesById ] + ); + + return { + selectedWorkspaceId, + getSelectedTargetId, + selectWorkspaceTarget, + }; +} diff --git a/apps/studio/src/modules/workspaces/index.ts b/apps/studio/src/modules/workspaces/index.ts index 9f835d1015..d186230039 100644 --- a/apps/studio/src/modules/workspaces/index.ts +++ b/apps/studio/src/modules/workspaces/index.ts @@ -3,6 +3,13 @@ export { createStudioWorkspaceId, mergeWpcomSitesWithConnectedSites, } from 'src/modules/workspaces/lib/build-studio-workspaces'; +export { + getDefaultWorkspaceTargetId, + getWorkspaceTargetStorageKey, + isWorkspaceTargetAvailable, +} from 'src/modules/workspaces/lib/target-selection'; +export { useSidebarWorkspaces } from 'src/modules/workspaces/hooks/use-sidebar-workspaces'; +export { useWorkspaceTargetSelection } from 'src/modules/workspaces/hooks/use-workspace-target-selection'; export type { BuildStudioWorkspacesInput, LocalTarget, diff --git a/apps/studio/src/modules/workspaces/lib/target-selection.ts b/apps/studio/src/modules/workspaces/lib/target-selection.ts new file mode 100644 index 0000000000..0dbd06ea44 --- /dev/null +++ b/apps/studio/src/modules/workspaces/lib/target-selection.ts @@ -0,0 +1,32 @@ +import type { StudioWorkspace, WorkspaceTargetId } from 'src/modules/workspaces/types'; + +const WORKSPACE_TARGET_STORAGE_PREFIX = 'studio-workspace-target:'; + +export function getDefaultWorkspaceTargetId( + workspace: StudioWorkspace +): WorkspaceTargetId | undefined { + if ( workspace.targets.local ) { + return 'local'; + } + + if ( workspace.targets.production ) { + return 'production'; + } + + if ( workspace.targets.staging ) { + return 'staging'; + } + + return undefined; +} + +export function isWorkspaceTargetAvailable( + workspace: StudioWorkspace, + targetId: WorkspaceTargetId +) { + return Boolean( workspace.targets[ targetId ] ); +} + +export function getWorkspaceTargetStorageKey( workspaceId: string ) { + return `${ WORKSPACE_TARGET_STORAGE_PREFIX }${ workspaceId }`; +} From 7882d9b9f2bb6c04e9967388ea2d327c86dc863e Mon Sep 17 00:00:00 2001 From: dereksmart Date: Fri, 15 May 2026 17:50:39 -0400 Subject: [PATCH 3/7] Add shared workspace shell What changed. Adds a `WorkspaceSelectionProvider` that reuses the sidebar workspace loader, tracks the selected workspace and target, persists target-scoped tab selection, and keeps Local target selection wired to the existing Studio site selection. Adds the shared workspace shell, header, target switcher, target-scoped tab model, and remote placeholder surfaces for Assistant, Sync, and Settings. Local targets continue to render the existing local tabs through the shared shell. Updates `App`, `SiteMenu`, and `SiteContentTabs` so remote-only workspaces can render app content when workspaces are enabled. Adds shell-owned preview controls for workspace targets, including remote and local previews, resizable preview width, back/forward/reload/open/close controls, webview-based rendering, CSP frame allowance, and guarded webview history access after `dom-ready`. What stayed unchanged / what is intentionally deferred. The flag-off local site content path remains unchanged. Local Assistant still uses the existing `ContentTabAssistant`, and remote Assistant, Sync, and Settings are placeholders/details surfaces only. This slice does not add Agenttic/Dolly transport, conversation state, staging creation, local target creation, or real sync/create actions. Preview is shell-owned and target-scoped, but Dolly preview tools are deferred. Tradeoffs and risks. The shell lands with non-mutating remote placeholder content so the layout and target-selection contract can be reviewed separately from Dolly and sync behavior. The largest risk is preview behavior inside Electron: remote and local targets need to render in-app without breaking renderer CSP or calling webview history APIs too early. The implementation uses the existing webview-enabled app boundary, adds the matching renderer `frame-src` allowance, and guards webview history reads until `dom-ready`. Verification. Verified with `npx eslint --fix `, `npm test -- apps/studio/src/components/tests/site-content-tabs.test.tsx apps/studio/src/components/tests/main-sidebar.test.tsx apps/studio/src/components/tests/app.test.tsx apps/studio/src/modules/workspaces/lib/build-studio-workspaces.test.ts`, `npm run typecheck`, and `git diff --check`. --- apps/studio/index.html | 2 +- apps/studio/src/components/app.tsx | 6 +- apps/studio/src/components/root.tsx | 17 +- .../src/components/site-content-tabs.tsx | 7 + apps/studio/src/components/site-menu.tsx | 35 +- apps/studio/src/components/tests/app.test.tsx | 87 +++- .../components/tests/main-sidebar.test.tsx | 5 +- .../tests/site-content-tabs.test.tsx | 294 +++++++++++++- .../components/workspace-content-shell.tsx | 377 ++++++++++++++++++ .../components/workspace-header.tsx | 122 ++++++ .../components/workspace-preview.tsx | 365 +++++++++++++++++ .../components/workspace-target-switcher.tsx | 129 ++++++ .../hooks/use-workspace-selection.tsx | 204 ++++++++++ apps/studio/src/modules/workspaces/index.ts | 12 + .../modules/workspaces/lib/workspace-tabs.ts | 34 ++ 15 files changed, 1650 insertions(+), 46 deletions(-) create mode 100644 apps/studio/src/modules/workspaces/components/workspace-content-shell.tsx create mode 100644 apps/studio/src/modules/workspaces/components/workspace-header.tsx create mode 100644 apps/studio/src/modules/workspaces/components/workspace-preview.tsx create mode 100644 apps/studio/src/modules/workspaces/components/workspace-target-switcher.tsx create mode 100644 apps/studio/src/modules/workspaces/hooks/use-workspace-selection.tsx create mode 100644 apps/studio/src/modules/workspaces/lib/workspace-tabs.ts diff --git a/apps/studio/index.html b/apps/studio/index.html index d44c0d682d..b088d982ac 100644 --- a/apps/studio/index.html +++ b/apps/studio/index.html @@ -3,7 +3,7 @@ - + WordPress Studio diff --git a/apps/studio/src/components/app.tsx b/apps/studio/src/components/app.tsx index c78ec9a787..c0a96148e3 100644 --- a/apps/studio/src/components/app.tsx +++ b/apps/studio/src/components/app.tsx @@ -24,6 +24,7 @@ import { UserSettings } from 'src/modules/user-settings'; import { useKonamiCode } from 'src/modules/wapuu-world/use-konami-code'; import { WapuuWorldGame } from 'src/modules/wapuu-world/wapuu-world-game'; import { WhatsNewModal, useWhatsNew } from 'src/modules/whats-new'; +import { useWorkspaceSelection } from 'src/modules/workspaces'; import { useAppDispatch, useRootSelector } from 'src/stores'; import { selectOnboardingLoading } from 'src/stores/onboarding-slice'; import { syncOperationsThunks } from 'src/stores/sync'; @@ -41,7 +42,10 @@ export default function App() { ); const { showWhatsNew, closeWhatsNew } = useWhatsNew(); const { sites: localSites, loadingSites } = useSiteDetails(); - const isEmpty = ! loadingSites && ! localSites.length; + const { enableWorkspaces, workspaces, isLoading: isLoadingWorkspaces } = useWorkspaceSelection(); + const isEmpty = enableWorkspaces + ? ! loadingSites && ! isLoadingWorkspaces && ! workspaces.length + : ! loadingSites && ! localSites.length; const shouldShowWhatsNew = showWhatsNew && ! isEmpty; const { client } = useAuth(); const dispatch = useAppDispatch(); diff --git a/apps/studio/src/components/root.tsx b/apps/studio/src/components/root.tsx index d23e6b0959..3386308f6e 100644 --- a/apps/studio/src/components/root.tsx +++ b/apps/studio/src/components/root.tsx @@ -15,6 +15,7 @@ import { ImportExportProvider } from 'src/hooks/use-import-export'; import { SiteDetailsProvider } from 'src/hooks/use-site-details'; import { ThemeDetailsProvider } from 'src/hooks/use-theme-details'; import { OnboardingProvider } from 'src/modules/onboarding/hooks/use-onboarding'; +import { WorkspaceSelectionProvider } from 'src/modules/workspaces'; import { store } from 'src/stores'; import { initializeUserLocale } from 'src/stores/i18n-slice'; @@ -39,13 +40,15 @@ const Root = () => { - - - - - - - + + + + + + + + + diff --git a/apps/studio/src/components/site-content-tabs.tsx b/apps/studio/src/components/site-content-tabs.tsx index 6468fb5c59..22f8ff27e7 100644 --- a/apps/studio/src/components/site-content-tabs.tsx +++ b/apps/studio/src/components/site-content-tabs.tsx @@ -11,15 +11,18 @@ import { SiteIsBeingCreated } from 'src/components/site-is-being-created'; import { MIN_WIDTH_CLASS_TO_MEASURE } from 'src/constants'; import { TabName } from 'src/hooks/use-content-tabs'; import { useEffectiveTab } from 'src/hooks/use-effective-tab'; +import { useFeatureFlags } from 'src/hooks/use-feature-flags'; import { useImportExport } from 'src/hooks/use-import-export'; import { useSiteDetails } from 'src/hooks/use-site-details'; import { cx } from 'src/lib/cx'; import { ContentTabSync } from 'src/modules/sync'; +import { WorkspaceContentShell } from 'src/modules/workspaces/components/workspace-content-shell'; export function SiteContentTabs() { const { selectedSite, siteCreationMessages } = useSiteDetails(); const { importState } = useImportExport(); const { effectiveTab, selectedTab, setSelectedTab, tabs } = useEffectiveTab(); + const { enableWorkspaces } = useFeatureFlags(); const { __ } = useI18n(); // Remount: Avoid focus loss on user tab changes (no remount), @@ -55,6 +58,10 @@ export function SiteContentTabs() { prevSelectedTab.current = selectedTab; }, [ selectedTab ] ); + if ( enableWorkspaces ) { + return ; + } + if ( ! selectedSite ) { return (
    diff --git a/apps/studio/src/components/site-menu.tsx b/apps/studio/src/components/site-menu.tsx index 1a6a5bbffe..a1833b1390 100644 --- a/apps/studio/src/components/site-menu.tsx +++ b/apps/studio/src/components/site-menu.tsx @@ -14,12 +14,11 @@ import { cx } from 'src/lib/cx'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { supportedEditorConfig } from 'src/modules/user-settings/lib/editor'; import { getTerminalName } from 'src/modules/user-settings/lib/terminal'; -import { useSidebarWorkspaces, useWorkspaceTargetSelection } from 'src/modules/workspaces'; +import { useWorkspaceSelection } from 'src/modules/workspaces'; import { WorkspaceSidebarRow } from 'src/modules/workspaces/components/workspace-sidebar-row'; import { useRootSelector } from 'src/stores'; import { useGetUserEditorQuery, useGetUserTerminalQuery } from 'src/stores/installed-apps-api'; import { syncOperationsSelectors } from 'src/stores/sync'; -import type { StudioWorkspace, WorkspaceTargetId } from 'src/modules/workspaces'; interface SiteMenuProps { className?: string; @@ -262,31 +261,15 @@ export default function SiteMenu( { className }: SiteMenuProps ) { const { data: editor } = useGetUserEditorQuery(); const { enableWorkspaces, - sidebarWorkspaces, + workspaces: sidebarWorkspaces, isLoading: isLoadingWorkspaces, - } = useSidebarWorkspaces(); - const { selectedWorkspaceId, getSelectedTargetId, selectWorkspaceTarget } = - useWorkspaceTargetSelection( sidebarWorkspaces ); + selectedWorkspaceId, + getSelectedTargetId, + selectWorkspaceTarget, + } = useWorkspaceSelection(); const [ draggedIndex, setDraggedIndex ] = useState< number | null >( null ); const [ dragOverIndex, setDragOverIndex ] = useState< number | null >( null ); - const handleSelectWorkspaceTarget = ( - workspace: StudioWorkspace, - targetId: WorkspaceTargetId - ) => { - selectWorkspaceTarget( workspace.id, targetId ); - if ( targetId === 'local' && workspace.targets.local ) { - setSelectedSiteId( workspace.targets.local.siteId ); - } - }; - - const isWorkspaceSelected = ( - workspace: StudioWorkspace, - selectedTargetId?: WorkspaceTargetId - ) => - selectedWorkspaceId === workspace.id || - ( selectedTargetId === 'local' && workspace.targets.local?.siteId === selectedSite?.id ); - const handleDragStart = ( e: React.DragEvent, index: number ) => { setDraggedIndex( index ); e.dataTransfer.effectAllowed = 'move'; @@ -428,15 +411,13 @@ export default function SiteMenu( { className }: SiteMenuProps ) { key={ workspace.id } workspace={ workspace } selectedTargetId={ selectedTargetId } - isSelected={ isWorkspaceSelected( workspace, selectedTargetId ) } + isSelected={ selectedWorkspaceId === workspace.id } localRunControl={ workspace.targets.local ? ( ) : undefined } - onSelectTarget={ ( targetId ) => - handleSelectWorkspaceTarget( workspace, targetId ) - } + onSelectTarget={ ( targetId ) => selectWorkspaceTarget( workspace.id, targetId ) } /> ); } ) } diff --git a/apps/studio/src/components/tests/app.test.tsx b/apps/studio/src/components/tests/app.test.tsx index 5af46df866..7374cd1f00 100644 --- a/apps/studio/src/components/tests/app.test.tsx +++ b/apps/studio/src/components/tests/app.test.tsx @@ -6,6 +6,7 @@ import App from 'src/components/app'; import { ContentTabsProvider } from 'src/hooks/use-content-tabs'; import { useSiteDetails } from 'src/hooks/use-site-details'; import { useOnboarding } from 'src/modules/onboarding/hooks/use-onboarding'; +import { WorkspaceSelectionProvider } from 'src/modules/workspaces'; import { rootReducer } from 'src/stores'; import { appVersionApi } from 'src/stores/app-version-api'; import { certificateTrustApi } from 'src/stores/certificate-trust-api'; @@ -14,11 +15,22 @@ import { connectedSitesApi } from 'src/stores/sync/connected-sites'; import { wpcomSitesApi } from 'src/stores/sync/wpcom-sites'; import { wordpressVersionsApi } from 'src/stores/wordpress-versions-api'; import { wpcomApi, wpcomPublicApi } from 'src/stores/wpcom-api'; +import type { SyncSite } from '@studio/common/types/sync'; + +const featureFlagsMock = vi.hoisted( () => ( { + enableBlueprints: true, + enableStudioCodeUi: false, + enableWorkspaces: false, +} ) ); +const useGetWpComSitesQueryMock = vi.hoisted( () => vi.fn() ); vi.mock( 'src/index.css', () => ( {} ) ); vi.mock( 'src/components/dot-grid', () => ( { DotGrid: () => null, } ) ); +vi.mock( 'src/components/gravatar', () => ( { + Gravatar: () => null, +} ) ); vi.mock( 'src/stores/onboarding-slice', async () => { const actual = await vi.importActual( 'src/stores/onboarding-slice' ); return { @@ -28,6 +40,18 @@ vi.mock( 'src/stores/onboarding-slice', async () => { } ); vi.mock( 'src/modules/onboarding/hooks/use-onboarding' ); vi.mock( 'src/hooks/use-site-details' ); +vi.mock( 'src/hooks/use-auth', () => ( { + useAuth: () => ( { + isAuthenticated: true, + user: { id: 123, email: 'user@example.com', displayName: 'User' }, + client: undefined, + authenticate: vi.fn(), + logout: vi.fn(), + } ), +} ) ); +vi.mock( 'src/hooks/use-feature-flags', () => ( { + useFeatureFlags: () => featureFlagsMock, +} ) ); vi.mock( 'src/modules/whats-new/hooks/use-whats-new', () => ( { useWhatsNew: () => ( { showWhatsNew: false, @@ -62,6 +86,7 @@ vi.mock( 'src/lib/get-ipc-api', async () => { } ), getAllCustomDomains: vi.fn().mockResolvedValue( [] ), generateSiteNameFromList: vi.fn().mockResolvedValue( 'My WordPress Website' ), + isFullscreen: vi.fn().mockResolvedValue( false ), } ), }; } ); @@ -91,9 +116,42 @@ vi.mock( 'src/stores/wpcom-api', async () => { }; } ); +vi.mock( 'src/stores/sync/wpcom-sites', async () => { + const actual = await vi.importActual< typeof import('src/stores/sync/wpcom-sites') >( + 'src/stores/sync/wpcom-sites' + ); + return { + ...actual, + useGetWpComSitesQuery: useGetWpComSitesQueryMock, + }; +} ); + +const createSyncSite = ( overrides: Partial< SyncSite > = {} ): SyncSite => ( { + id: 101, + localSiteId: '', + name: 'Remote Only', + url: 'https://remote-only.example', + isStaging: false, + isPressable: false, + syncSupport: 'syncable', + lastPullTimestamp: null, + lastPushTimestamp: null, + ...overrides, +} ); + +const mockWpcomSitesQuery = ( sites: SyncSite[] = [] ) => { + useGetWpComSitesQueryMock.mockReturnValue( { + data: { sites, total: sites.length, page: 1, perPage: 100 }, + isLoading: false, + isFetching: false, + } ); +}; + describe( 'App', () => { beforeEach( () => { vi.clearAllMocks(); + featureFlagsMock.enableWorkspaces = false; + mockWpcomSitesQuery(); } ); const renderWithProvider = ( component: React.ReactElement ) => { @@ -112,7 +170,9 @@ describe( 'App', () => { } ); return render( - { component } + + { component } + ); }; @@ -135,4 +195,29 @@ describe( 'App', () => { expect( screen.getByText( 'Add a site' ) ).toBeInTheDocument(); } ); } ); + + it( 'renders workspace content for remote-only workspaces when enabled', async () => { + featureFlagsMock.enableWorkspaces = true; + mockWpcomSitesQuery( [ createSyncSite() ] ); + ( useOnboarding as Mock ).mockReturnValue( { + needsOnboarding: false, + } ); + ( useSiteDetails as Mock ).mockReturnValue( { + sites: [], + loadingSites: false, + selectedSite: null, + snapshots: [], + loadingServer: {}, + siteCreationMessages: {}, + setSelectedSiteId: vi.fn(), + } ); + + renderWithProvider( ); + + await waitFor( () => { + expect( screen.getByTestId( 'site-content' ) ).toBeInTheDocument(); + } ); + expect( screen.getAllByText( 'Remote Only' )[ 0 ] ).toBeVisible(); + expect( screen.queryByText( 'Add a site' ) ).not.toBeInTheDocument(); + } ); } ); diff --git a/apps/studio/src/components/tests/main-sidebar.test.tsx b/apps/studio/src/components/tests/main-sidebar.test.tsx index 96d69301ed..98cadcfb12 100644 --- a/apps/studio/src/components/tests/main-sidebar.test.tsx +++ b/apps/studio/src/components/tests/main-sidebar.test.tsx @@ -5,6 +5,7 @@ import { vi } from 'vitest'; import MainSidebar from 'src/components/main-sidebar'; import { useAuth } from 'src/hooks/use-auth'; import { ContentTabsProvider } from 'src/hooks/use-content-tabs'; +import { WorkspaceSelectionProvider } from 'src/modules/workspaces'; import { store } from 'src/stores'; import type { SyncSite } from '@studio/common/types/sync'; @@ -182,7 +183,9 @@ const enableWorkspaceSidebar = ( { const renderWithProvider = ( children: React.ReactElement ) => { return render( - { children } + + { children } + ); }; diff --git a/apps/studio/src/components/tests/site-content-tabs.test.tsx b/apps/studio/src/components/tests/site-content-tabs.test.tsx index e2807e0f5d..3538e46691 100644 --- a/apps/studio/src/components/tests/site-content-tabs.test.tsx +++ b/apps/studio/src/components/tests/site-content-tabs.test.tsx @@ -1,11 +1,21 @@ -import { act, render, screen } from '@testing-library/react'; +import { act, render, screen, within } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; import { Provider } from 'react-redux'; import { vi } from 'vitest'; import { SiteContentTabs } from 'src/components/site-content-tabs'; import { ContentTabsProvider } from 'src/hooks/use-content-tabs'; import { useSiteDetails } from 'src/hooks/use-site-details'; +import { WorkspaceSelectionProvider, getWorkspaceTargetStorageKey } from 'src/modules/workspaces'; import { store } from 'src/stores'; import { testActions, testReducer } from 'src/stores/tests/utils/test-reducer'; +import type { SyncSite } from '@studio/common/types/sync'; + +const featureFlagsMock = vi.hoisted( () => ( { + enableBlueprints: true, + enableStudioCodeUi: false, + enableWorkspaces: false, +} ) ); +const useGetWpComSitesQueryMock = vi.hoisted( () => vi.fn() ); const selectedSite: SiteDetails = { id: 'site-id-1', @@ -17,10 +27,15 @@ const selectedSite: SiteDetails = { }; vi.mock( 'src/hooks/use-site-details' ); +vi.mock( 'src/hooks/use-feature-flags', () => ( { + useFeatureFlags: () => featureFlagsMock, +} ) ); vi.mock( 'src/hooks/use-auth', () => ( { useAuth: () => ( { isAuthenticated: true, authenticate: vi.fn(), + user: { id: 123, email: 'user@example.com', displayName: 'User' }, + client: {} as never, } ), } ) ); vi.mock( 'src/lib/app-globals', async () => ( { @@ -65,27 +80,83 @@ vi.mock( 'src/stores/wpcom-api', async () => { }; } ); +vi.mock( 'src/stores/sync/wpcom-sites', async () => { + const actual = await vi.importActual< typeof import('src/stores/sync/wpcom-sites') >( + 'src/stores/sync/wpcom-sites' + ); + return { + ...actual, + useGetWpComSitesQuery: useGetWpComSitesQueryMock, + }; +} ); + store.replaceReducer( testReducer ); +const createSyncSite = ( overrides: Partial< SyncSite > = {} ): SyncSite => ( { + id: 101, + localSiteId: '', + name: 'Remote Site', + url: 'https://remote.example', + isStaging: false, + isPressable: false, + syncSupport: 'syncable', + lastPullTimestamp: null, + lastPushTimestamp: null, + ...overrides, +} ); + +const createSiteDetailsReturn = ( { + selectedSite: selectedSiteValue = selectedSite, + sites = [ selectedSite ], + setSelectedSiteId = vi.fn(), + startServer = vi.fn(), +}: { + selectedSite?: SiteDetails | null; + sites?: SiteDetails[]; + setSelectedSiteId?: ReturnType< typeof vi.fn >; + startServer?: ReturnType< typeof vi.fn >; +} = {} ) => + ( { + selectedSite: selectedSiteValue, + sites, + loadingServer: {}, + siteCreationMessages: {}, + setSelectedSiteId, + startServer, + stopServer: vi.fn(), + } ) as Partial< ReturnType< typeof useSiteDetails > >; + +const mockWpcomSitesQuery = ( sites: SyncSite[] = [] ) => { + useGetWpComSitesQueryMock.mockReturnValue( { + data: { sites, total: sites.length, page: 1, perPage: 100 }, + isLoading: false, + isFetching: false, + } ); +}; + describe( 'SiteContentTabs', () => { beforeEach( () => { vi.clearAllMocks(); // Clear mock call history between tests + localStorage.clear(); + featureFlagsMock.enableWorkspaces = false; + mockWpcomSitesQuery(); store.dispatch( testActions.resetState() ); } ); const renderWithProvider = ( component: React.ReactElement ) => { return render( - { component } + + { component } + ); }; it( 'should render tabs correctly if selected site exists', async () => { vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { - selectedSite, - sites: [ selectedSite ], - loadingServer: {}, + ...createSiteDetailsReturn(), } ); await act( async () => renderWithProvider( ) ); + expect( screen.getByTestId( 'site-content-header' ) ).toBeInTheDocument(); expect( screen.getByRole( 'tab', { name: 'Settings' } ) ).toBeInTheDocument(); expect( screen.getByRole( 'tab', { name: 'Sync' } ) ).toBeInTheDocument(); expect( screen.getByRole( 'tab', { name: 'Previews' } ) ).toBeInTheDocument(); @@ -96,9 +167,7 @@ describe( 'SiteContentTabs', () => { } ); it( 'selects the Overview tab by default', async () => { vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { - selectedSite, - sites: [ selectedSite ], - loadingServer: {}, + ...createSiteDetailsReturn(), } ); await act( async () => renderWithProvider( ) ); expect( screen.queryByRole( 'tab', { name: 'Overview', selected: true } ) ).toBeVisible(); @@ -110,4 +179,213 @@ describe( 'SiteContentTabs', () => { screen.queryByRole( 'tab', { name: 'Backup', selected: false } ) ).not.toBeInTheDocument(); } ); + + it( 'renders the shared workspace shell for a local target when enabled', async () => { + featureFlagsMock.enableWorkspaces = true; + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn(), + } ); + + await act( async () => renderWithProvider( ) ); + + expect( screen.getByTestId( 'workspace-content-header' ) ).toBeInTheDocument(); + expect( screen.getByRole( 'tab', { name: 'Overview' } ) ).toBeVisible(); + expect( screen.getByRole( 'tab', { name: 'Previews' } ) ).toBeVisible(); + expect( screen.getByRole( 'tab', { name: 'Import / Export' } ) ).toBeVisible(); + expect( + within( screen.getByTestId( 'workspace-preview-controls' ) ).getByRole( 'button', { + name: 'Show preview', + } ) + ).toBeVisible(); + } ); + + it( 'starts local sites before opening local preview in the shared shell', async () => { + const user = userEvent.setup(); + const startServer = vi.fn( () => Promise.resolve() ); + featureFlagsMock.enableWorkspaces = true; + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn( { startServer } ), + } ); + + await act( async () => renderWithProvider( ) ); + await user.click( + within( screen.getByTestId( 'workspace-preview-controls' ) ).getByRole( 'button', { + name: 'Show preview', + } ) + ); + + expect( startServer ).toHaveBeenCalledWith( selectedSite ); + expect( + within( screen.getByTestId( 'workspace-content-body' ) ).getByLabelText( + 'Workspace site preview' + ) + ).toBeVisible(); + expect( screen.getByTitle( 'Test Site preview' ) ).toHaveAttribute( + 'src', + 'http://localhost:8881/' + ); + } ); + + it( 'renders remote Production targets with remote tabs only', async () => { + featureFlagsMock.enableWorkspaces = true; + mockWpcomSitesQuery( [ createSyncSite( { id: 101, name: 'Remote Only' } ) ] ); + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn( { selectedSite: null, sites: [] } ), + } ); + + await act( async () => renderWithProvider( ) ); + + expect( screen.getByTestId( 'workspace-content-header' ) ).toBeInTheDocument(); + expect( screen.getByRole( 'tab', { name: 'Assistant', selected: true } ) ).toBeVisible(); + expect( screen.getByRole( 'tab', { name: 'Assistant', selected: true } ) ).toHaveClass( + 'components-tab-panel__tabs--assistant' + ); + expect( screen.getByRole( 'tab', { name: 'Assistant', selected: true } ) ).not.toHaveClass( + 'ltr:ml-auto' + ); + expect( screen.getByRole( 'tab', { name: 'Sync' } ) ).toBeVisible(); + expect( screen.getByRole( 'tab', { name: 'Settings' } ) ).toBeVisible(); + expect( screen.queryByRole( 'tab', { name: 'Overview' } ) ).not.toBeInTheDocument(); + expect( screen.queryByRole( 'tab', { name: 'Previews' } ) ).not.toBeInTheDocument(); + expect( + within( screen.getByTestId( 'workspace-preview-controls' ) ).getByRole( 'button', { + name: 'Show preview', + } ) + ).toBeVisible(); + expect( + within( screen.getByTestId( 'workspace-content-header' ) ).queryByRole( 'button', { + name: 'Show preview', + } ) + ).not.toBeInTheDocument(); + } ); + + it( 'opens remote preview under the workspace header', async () => { + const user = userEvent.setup(); + featureFlagsMock.enableWorkspaces = true; + mockWpcomSitesQuery( [ createSyncSite( { id: 101, name: 'Remote Only' } ) ] ); + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn( { selectedSite: null, sites: [] } ), + } ); + + await act( async () => renderWithProvider( ) ); + await user.click( + within( screen.getByTestId( 'workspace-preview-controls' ) ).getByRole( 'button', { + name: 'Show preview', + } ) + ); + + expect( + within( screen.getByTestId( 'workspace-preview-controls' ) ).getByRole( 'button', { + name: 'Reload preview', + } ) + ).toBeVisible(); + expect( + within( screen.getByTestId( 'workspace-preview-controls' ) ).getByRole( 'button', { + name: 'Back', + } ) + ).toBeDisabled(); + expect( + within( screen.getByTestId( 'workspace-preview-controls' ) ).getByRole( 'button', { + name: 'Forward', + } ) + ).toBeDisabled(); + expect( + within( screen.getByTestId( 'workspace-content-body' ) ).getByLabelText( + 'Workspace site preview' + ) + ).toBeVisible(); + const resizeHandle = within( screen.getByTestId( 'workspace-content-body' ) ).getByRole( + 'separator', + { + name: 'Resize preview', + } + ); + expect( resizeHandle ).toHaveAttribute( 'aria-valuenow', '520' ); + + resizeHandle.focus(); + await user.keyboard( '{ArrowLeft}' ); + + expect( resizeHandle ).toHaveAttribute( 'aria-valuenow', '552' ); + } ); + + it( 'switches remote workspace targets inside the shell', async () => { + const user = userEvent.setup(); + featureFlagsMock.enableWorkspaces = true; + const productionSite = createSyncSite( { + id: 101, + name: 'Remote Workspace', + url: 'https://production.example', + stagingSiteIds: [ 202 ], + } ); + const stagingSite = createSyncSite( { + id: 202, + name: 'Remote Workspace Staging', + url: 'https://staging.example', + isStaging: true, + productionSiteId: 101, + } ); + mockWpcomSitesQuery( [ productionSite, stagingSite ] ); + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn( { selectedSite: null, sites: [] } ), + } ); + + await act( async () => renderWithProvider( ) ); + await user.click( + screen.getByRole( 'button', { name: 'Select Staging target: https://staging.example' } ) + ); + + expect( + screen.getByRole( 'button', { name: 'Select Staging target: https://staging.example' } ) + ).toHaveClass( 'bg-a8c-green-5' ); + expect( screen.getByRole( 'tab', { name: 'Assistant', selected: true } ) ).toBeVisible(); + } ); + + it( 'selects the local Studio site when switching to Local from the shell', async () => { + const user = userEvent.setup(); + const setSelectedSiteId = vi.fn< ( selectedSiteId: string ) => void >(); + featureFlagsMock.enableWorkspaces = true; + localStorage.setItem( + getWorkspaceTargetStorageKey( 'studio-workspace:wpcom:101' ), + 'production' + ); + mockWpcomSitesQuery( [ + createSyncSite( { + id: 101, + localSiteId: selectedSite.id, + name: 'Linked Workspace', + url: 'https://linked.example', + } ), + ] ); + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn( { + selectedSite, + sites: [ selectedSite ], + setSelectedSiteId, + } ), + } ); + + await act( async () => renderWithProvider( ) ); + await user.click( + screen.getByRole( 'button', { name: 'Select Local target: Test Site is stopped' } ) + ); + + expect( setSelectedSiteId ).toHaveBeenCalledWith( selectedSite.id ); + } ); + + it( 'shows missing targets as disabled affordances', async () => { + featureFlagsMock.enableWorkspaces = true; + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn(), + } ); + + await act( async () => renderWithProvider( ) ); + + expect( + screen.getByRole( 'button', { name: 'Production target unavailable' } ) + ).toBeDisabled(); + expect( screen.getByRole( 'button', { name: 'Staging target unavailable' } ) ).toBeDisabled(); + expect( + screen.getByRole( 'button', { name: 'Select Local target: Test Site is stopped' } ) + ).toBeEnabled(); + } ); } ); diff --git a/apps/studio/src/modules/workspaces/components/workspace-content-shell.tsx b/apps/studio/src/modules/workspaces/components/workspace-content-shell.tsx new file mode 100644 index 0000000000..0a4f44b5e2 --- /dev/null +++ b/apps/studio/src/modules/workspaces/components/workspace-content-shell.tsx @@ -0,0 +1,377 @@ +import { TabPanel } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { useCallback, useMemo, useState, type ComponentProps } from 'react'; +import { ContentTabAssistant } from 'src/components/content-tab-assistant'; +import { ContentTabImportExport } from 'src/components/content-tab-import-export'; +import { ContentTabOverview } from 'src/components/content-tab-overview'; +import { ContentTabPreviews } from 'src/components/content-tab-previews'; +import { ContentTabSettings } from 'src/components/content-tab-settings'; +import { SiteIsBeingCreated } from 'src/components/site-is-being-created'; +import { MIN_WIDTH_CLASS_TO_MEASURE } from 'src/constants'; +import { useContentTabs, type TabName } from 'src/hooks/use-content-tabs'; +import { useImportExport } from 'src/hooks/use-import-export'; +import { useSiteDetails } from 'src/hooks/use-site-details'; +import { cx } from 'src/lib/cx'; +import { ContentTabSync } from 'src/modules/sync'; +import { + getDefaultWorkspaceTargetTabId, + getWorkspaceTargetTabIds, + useWorkspaceSelection, +} from 'src/modules/workspaces'; +import { WorkspaceHeader } from 'src/modules/workspaces/components/workspace-header'; +import { + createDefaultWorkspacePreviewState, + resolveWorkspacePreviewUrl, + WorkspacePreviewControls, + WorkspacePreviewPanel, + type WorkspacePreviewState, + type WorkspacePreviewTarget, +} from 'src/modules/workspaces/components/workspace-preview'; +import type { SyncSite } from '@studio/common/types/sync'; +import type { + RemoteTarget, + StudioWorkspace, + WorkspaceTargetId, +} from 'src/modules/workspaces/types'; + +function EmptyWorkspaceSelection() { + return ( +
    +

    + { __( 'Select a workspace to view details.' ) } +

    +
    + ); +} + +function RemoteAssistantPlaceholder( { target }: { target: RemoteTarget } ) { + return ( +
    +
    +

    { __( 'Assistant' ) }

    +

    { target.site.name }

    +
    +
    + ); +} + +function RemoteSyncPlaceholder( { + workspace, + target, +}: { + workspace: StudioWorkspace; + target: RemoteTarget; +} ) { + const syncLinksForTarget = workspace.syncLinks.filter( + ( link ) => link.source === target.id || link.target === target.id + ); + + return ( +
    +
    +

    { __( 'Sync' ) }

    +
    + { syncLinksForTarget.length ? ( + syncLinksForTarget.map( ( link ) => ( +
    + { link.source } <-> { link.target } +
    + ) ) + ) : ( +
    + { __( 'No workspace sync links are available for this target.' ) } +
    + ) } +
    +
    +
    + ); +} + +function SettingsRow( { label, value }: { label: string; value?: string | number | null } ) { + return ( +
    +
    { label }
    +
    { value || __( 'Unknown' ) }
    +
    + ); +} + +function RemoteSettings( { site }: { site: SyncSite } ) { + return ( +
    +
    +

    { __( 'Settings' ) }

    +
    + + + + +
    +
    +
    + ); +} + +function getOrderedWorkspaceTabs( + tabs: ComponentProps< typeof TabPanel >[ 'tabs' ], + targetId: WorkspaceTargetId +) { + const tabsByName = new Map( tabs.map( ( tab ) => [ tab.name, tab ] ) ); + return getWorkspaceTargetTabIds( targetId ) + .map( ( tabId ) => tabsByName.get( tabId ) ) + .filter( ( tab ): tab is NonNullable< typeof tab > => Boolean( tab ) ) + .map( ( tab ) => ( { + ...tab, + className: tab.className?.replace( /\s*ltr:ml-auto\s+rtl:mr-auto\s*/g, ' ' ).trim(), + } ) ); +} + +function renderRemoteTabContent( { + workspace, + target, + name, +}: { + workspace: StudioWorkspace; + target: RemoteTarget; + name: TabName; +} ) { + if ( name === 'sync' ) { + return ; + } + + if ( name === 'settings' ) { + return ; + } + + return ; +} + +function resolveLocalPreviewBaseUrl( site: SiteDetails ) { + if ( site.running ) { + return site.url; + } + + const protocol = site.customDomain && site.enableHttps ? 'https' : 'http'; + const domain = site.customDomain || `localhost:${ site.port }`; + + return `${ protocol }://${ domain }`; +} + +export function WorkspaceContentShell() { + const { tabs } = useContentTabs(); + const { importState } = useImportExport(); + const { loadingServer, siteCreationMessages, startServer } = useSiteDetails(); + const { + selectedWorkspace, + selectedTarget, + selectedTargetId, + selectedTabId, + selectWorkspaceTarget, + selectWorkspaceTab, + } = useWorkspaceSelection(); + const [ previewStates, setPreviewStates ] = useState< Record< string, WorkspacePreviewState > >( + {} + ); + + const workspaceTabs = useMemo( + () => ( selectedTargetId ? getOrderedWorkspaceTabs( tabs, selectedTargetId ) : [] ), + [ selectedTargetId, tabs ] + ); + const previewKey = + selectedWorkspace && selectedTargetId ? `${ selectedWorkspace.id }:${ selectedTargetId }` : ''; + + const updatePreviewState = useCallback( + ( nextPreviewState: WorkspacePreviewState ) => { + if ( ! previewKey ) { + return; + } + + setPreviewStates( ( current ) => ( { + ...current, + [ previewKey ]: nextPreviewState, + } ) ); + }, + [ previewKey ] + ); + + const updatePreviewNavigationState = useCallback( + ( + navigationState: Pick< WorkspacePreviewState, 'canGoBack' | 'canGoForward' | 'currentUrl' > + ) => { + if ( ! previewKey ) { + return; + } + + setPreviewStates( ( current ) => { + const currentPreviewState = current[ previewKey ] ?? createDefaultWorkspacePreviewState(); + + if ( + currentPreviewState.canGoBack === navigationState.canGoBack && + currentPreviewState.canGoForward === navigationState.canGoForward && + currentPreviewState.currentUrl === navigationState.currentUrl + ) { + return current; + } + + return { + ...current, + [ previewKey ]: { + ...currentPreviewState, + ...navigationState, + }, + }; + } ); + }, + [ previewKey ] + ); + + if ( ! selectedWorkspace || ! selectedTarget || ! selectedTargetId ) { + return ; + } + + if ( selectedTarget.kind === 'local' ) { + const selectedSite = selectedTarget.site; + const siteImportState = importState[ selectedSite.id ]; + const creationMessage = selectedSite.id ? siteCreationMessages[ selectedSite.id ] : undefined; + + if ( selectedSite.isAddingSite || siteImportState?.isNewSite ) { + return ( + + ); + } + } + + const activeTabId = selectedTabId ?? getDefaultWorkspaceTargetTabId( selectedTargetId ); + const previewState = previewStates[ previewKey ] ?? createDefaultWorkspacePreviewState(); + const remoteTarget = selectedTarget.kind === 'remote' ? selectedTarget : undefined; + const localTarget = selectedTarget.kind === 'local' ? selectedTarget : undefined; + let previewTarget: WorkspacePreviewTarget | undefined; + if ( remoteTarget ) { + previewTarget = { + siteName: remoteTarget.site.name, + siteUrl: remoteTarget.site.url, + }; + } else if ( localTarget ) { + previewTarget = { + siteName: localTarget.site.name, + siteUrl: resolveLocalPreviewBaseUrl( localTarget.site ), + isLoading: loadingServer[ localTarget.site.id ], + onShowPreview: async () => { + if ( ! localTarget.site.running ) { + await startServer( localTarget.site ); + } + }, + }; + } + const resolvedPreviewUrl = previewTarget + ? resolveWorkspacePreviewUrl( previewTarget.siteUrl, previewState.pathOrUrl ) + : undefined; + const previewUrl = previewState.currentUrl ?? resolvedPreviewUrl; + + return ( +
    + selectWorkspaceTarget( selectedWorkspace.id, targetId ) } + /> +
    + { previewTarget && ( +
    +
    + +
    +
    + ) } +
    + + selectWorkspaceTab( selectedWorkspace.id, selectedTargetId, tabName as TabName ) + } + initialTabName={ activeTabId } + key={ `${ selectedWorkspace.id }-${ selectedTargetId }` } + > + { ( { name } ) => ( +
    + { selectedTarget.kind === 'local' && ( + <> + { name === 'overview' && ( + + ) } + { name === 'previews' && ( + + ) } + { name === 'sync' && } + { name === 'settings' && ( + + ) } + { name === 'assistant' && ( + + ) } + { name === 'import-export' && ( + + ) } + + ) } + { remoteTarget && + renderRemoteTabContent( { + workspace: selectedWorkspace, + target: remoteTarget, + name: name as TabName, + } ) } +
    + ) } +
    +
    + { previewTarget && previewState.open && previewUrl && ( + updatePreviewState( { ...previewState, width } ) } + onNavigationStateChange={ updatePreviewNavigationState } + /> + ) } +
    +
    + ); +} diff --git a/apps/studio/src/modules/workspaces/components/workspace-header.tsx b/apps/studio/src/modules/workspaces/components/workspace-header.tsx new file mode 100644 index 0000000000..258b092c32 --- /dev/null +++ b/apps/studio/src/modules/workspaces/components/workspace-header.tsx @@ -0,0 +1,122 @@ +import { useI18n } from '@wordpress/react-i18n'; +import { ArrowIcon } from 'src/components/arrow-icon'; +import Button from 'src/components/button'; +import { SiteManagementActions } from 'src/components/site-management-actions'; +import { useSiteDetails } from 'src/hooks/use-site-details'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import { WorkspaceTargetSwitcher } from 'src/modules/workspaces/components/workspace-target-switcher'; +import type { + StudioWorkspace, + WorkspaceTargetId, + LocalTarget, + RemoteTarget, +} from 'src/modules/workspaces/types'; + +type WorkspaceHeaderProps = { + workspace: StudioWorkspace; + selectedTargetId: WorkspaceTargetId; + selectedTarget: LocalTarget | RemoteTarget; + onSelectTarget: ( targetId: WorkspaceTargetId ) => void; +}; + +function resolveRemoteUrl( siteUrl: string, path = '/' ) { + try { + return new URL( path, siteUrl ).toString(); + } catch { + return siteUrl; + } +} + +export function WorkspaceHeader( { + workspace, + selectedTargetId, + selectedTarget, + onSelectTarget, +}: WorkspaceHeaderProps ) { + const { __ } = useI18n(); + const { startServer, stopServer, loadingServer } = useSiteDetails(); + const localSite = selectedTarget.kind === 'local' ? selectedTarget.site : undefined; + const remoteSite = selectedTarget.kind === 'remote' ? selectedTarget.site : undefined; + const isLoading = localSite?.id ? loadingServer[ localSite.id ] : false; + const displayTitle = workspace.name || selectedTarget.site.name; + + const handleWpAdminClick = async () => { + if ( localSite ) { + if ( isLoading ) { + return; + } + if ( ! localSite.running ) { + await startServer( localSite ); + } + getIpcApi().openSiteURL( localSite.id, '/wp-admin/' ); + return; + } + + if ( remoteSite ) { + getIpcApi().openURL( resolveRemoteUrl( remoteSite.url, '/wp-admin/' ) ); + } + }; + + const handleOpenSiteClick = async () => { + if ( localSite ) { + if ( isLoading ) { + return; + } + if ( ! localSite.running ) { + await startServer( localSite ); + } + getIpcApi().openSiteURL( localSite.id, '', { autoLogin: false } ); + return; + } + + if ( remoteSite ) { + getIpcApi().openURL( remoteSite.url ); + } + }; + + return ( +
    +
    +

    { displayTitle }

    +
    + + +
    +
    + +
    +
    + { localSite && ( + + ) } +
    + ); +} diff --git a/apps/studio/src/modules/workspaces/components/workspace-preview.tsx b/apps/studio/src/modules/workspaces/components/workspace-preview.tsx new file mode 100644 index 0000000000..e27f3face0 --- /dev/null +++ b/apps/studio/src/modules/workspaces/components/workspace-preview.tsx @@ -0,0 +1,365 @@ +import { __ } from '@wordpress/i18n'; +import { + chevronLeft, + chevronRight, + closeSmall, + desktop, + external, + Icon, + lockSmall, + rotateRight, +} from '@wordpress/icons'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import Button from 'src/components/button'; +import { getIpcApi } from 'src/lib/get-ipc-api'; + +const DEFAULT_PREVIEW_WIDTH = 520; +const MIN_PREVIEW_WIDTH = 360; +const MAX_PREVIEW_WIDTH = 920; +const PREVIEW_TOP_OFFSET = 40; + +type WorkspacePreviewNavigationAction = 'back' | 'forward'; + +type WorkspacePreviewWebview = HTMLElement & { + canGoBack?: () => boolean; + canGoForward?: () => boolean; + getURL?: () => string; + goBack?: () => void; + goForward?: () => void; +}; + +function clampPreviewWidth( width: number ) { + return Math.min( MAX_PREVIEW_WIDTH, Math.max( MIN_PREVIEW_WIDTH, Math.round( width ) ) ); +} + +export type WorkspacePreviewState = { + open: boolean; + pathOrUrl: string; + reloadNonce: number; + width: number; + canGoBack: boolean; + canGoForward: boolean; + currentUrl?: string; + navigationAction?: WorkspacePreviewNavigationAction; + navigationActionId: number; +}; + +export type WorkspacePreviewTarget = { + siteName: string; + siteUrl: string; + isLoading?: boolean; + onShowPreview?: () => Promise< void > | void; +}; + +type WorkspacePreviewControlsProps = { + target: WorkspacePreviewTarget; + previewState: WorkspacePreviewState; + onUpdatePreviewState: ( state: WorkspacePreviewState ) => void; +}; + +type WorkspacePreviewPanelProps = { + siteName: string; + previewUrl: string; + reloadNonce: number; + width: number; + navigationAction?: WorkspacePreviewNavigationAction; + navigationActionId: number; + onResize: ( width: number ) => void; + onNavigationStateChange: ( state: { + canGoBack: boolean; + canGoForward: boolean; + currentUrl?: string; + } ) => void; +}; + +export const createDefaultWorkspacePreviewState = (): WorkspacePreviewState => ( { + open: false, + pathOrUrl: '/', + reloadNonce: 0, + width: DEFAULT_PREVIEW_WIDTH, + canGoBack: false, + canGoForward: false, + navigationActionId: 0, +} ); + +export function resolveWorkspacePreviewUrl( siteUrl: string, pathOrUrl: string ) { + try { + return new URL( pathOrUrl, siteUrl ).toString(); + } catch { + return siteUrl; + } +} + +export function WorkspacePreviewControls( { + target, + previewState, + onUpdatePreviewState, +}: WorkspacePreviewControlsProps ) { + const previewUrl = resolveWorkspacePreviewUrl( target.siteUrl, previewState.pathOrUrl ); + const displayUrl = previewState.currentUrl ?? previewUrl; + const showPreview = async () => { + await target.onShowPreview?.(); + onUpdatePreviewState( { + ...previewState, + currentUrl: previewUrl, + open: true, + } ); + }; + const navigatePreview = ( action: WorkspacePreviewNavigationAction ) => { + onUpdatePreviewState( { + ...previewState, + navigationAction: action, + navigationActionId: previewState.navigationActionId + 1, + } ); + }; + + if ( ! previewState.open ) { + return ( + + ); + } + + return ( +
    + + + +
    + +
    { displayUrl }
    +
    + + +
    + ); +} + +export function WorkspacePreviewPanel( { + siteName, + previewUrl, + reloadNonce, + width, + navigationAction, + navigationActionId, + onResize, + onNavigationStateChange, +}: WorkspacePreviewPanelProps ) { + const [ isResizing, setIsResizing ] = useState( false ); + const panelRef = useRef< HTMLElement >( null ); + const webviewRef = useRef< WorkspacePreviewWebview >( null ); + const isWebviewReady = useRef( false ); + const handledNavigationActionId = useRef( 0 ); + const panelWidth = clampPreviewWidth( width ); + + const updateNavigationState = useCallback( () => { + const webview = webviewRef.current; + if ( ! webview || ! isWebviewReady.current ) { + onNavigationStateChange( { + canGoBack: false, + canGoForward: false, + currentUrl: previewUrl, + } ); + return; + } + + try { + onNavigationStateChange( { + canGoBack: webview.canGoBack?.() ?? false, + canGoForward: webview.canGoForward?.() ?? false, + currentUrl: webview.getURL?.() || previewUrl, + } ); + } catch { + onNavigationStateChange( { + canGoBack: false, + canGoForward: false, + currentUrl: previewUrl, + } ); + } + }, [ onNavigationStateChange, previewUrl ] ); + + const handleMouseMove = useCallback( + ( event: MouseEvent ) => { + const panelRight = panelRef.current?.getBoundingClientRect().right ?? window.innerWidth; + const nextWidth = panelRight - event.clientX; + onResize( clampPreviewWidth( nextWidth ) ); + }, + [ onResize ] + ); + + const stopResizing = useCallback( () => { + setIsResizing( false ); + }, [] ); + + useEffect( () => { + if ( ! isResizing ) { + return; + } + + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + window.addEventListener( 'mousemove', handleMouseMove ); + window.addEventListener( 'mouseup', stopResizing ); + + return () => { + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + window.removeEventListener( 'mousemove', handleMouseMove ); + window.removeEventListener( 'mouseup', stopResizing ); + }; + }, [ handleMouseMove, isResizing, stopResizing ] ); + + const resizeBy = ( delta: number ) => { + onResize( clampPreviewWidth( panelWidth + delta ) ); + }; + + useEffect( () => { + if ( ! navigationAction || navigationActionId === handledNavigationActionId.current ) { + return; + } + + const webview = webviewRef.current; + if ( webview && isWebviewReady.current ) { + try { + if ( navigationAction === 'back' && webview.canGoBack?.() ) { + webview.goBack?.(); + } + if ( navigationAction === 'forward' && webview.canGoForward?.() ) { + webview.goForward?.(); + } + } catch { + updateNavigationState(); + } + } + handledNavigationActionId.current = navigationActionId; + }, [ navigationAction, navigationActionId, updateNavigationState ] ); + + useEffect( () => { + const webview = webviewRef.current; + if ( ! webview ) { + return; + } + + isWebviewReady.current = false; + const updateState = () => updateNavigationState(); + const handleDomReady = () => { + isWebviewReady.current = true; + updateNavigationState(); + }; + const events = [ 'did-finish-load', 'did-navigate', 'did-navigate-in-page' ]; + webview.addEventListener( 'dom-ready', handleDomReady ); + events.forEach( ( eventName ) => webview.addEventListener( eventName, updateState ) ); + updateNavigationState(); + + return () => { + isWebviewReady.current = false; + webview.removeEventListener( 'dom-ready', handleDomReady ); + events.forEach( ( eventName ) => webview.removeEventListener( eventName, updateState ) ); + }; + }, [ reloadNonce, updateNavigationState ] ); + + return ( + + ); +} diff --git a/apps/studio/src/modules/workspaces/components/workspace-target-switcher.tsx b/apps/studio/src/modules/workspaces/components/workspace-target-switcher.tsx new file mode 100644 index 0000000000..627dd8afa8 --- /dev/null +++ b/apps/studio/src/modules/workspaces/components/workspace-target-switcher.tsx @@ -0,0 +1,129 @@ +import { __, sprintf } from '@wordpress/i18n'; +import { Tooltip } from 'src/components/tooltip'; +import { cx } from 'src/lib/cx'; +import type { StudioWorkspace, WorkspaceTargetId } from 'src/modules/workspaces/types'; + +type WorkspaceTargetSwitcherProps = { + workspace: StudioWorkspace; + selectedTargetId?: WorkspaceTargetId; + onSelectTarget: ( targetId: WorkspaceTargetId ) => void; +}; + +const TARGET_ORDER: WorkspaceTargetId[] = [ 'production', 'staging', 'local' ]; + +function getTargetLabel( targetId: WorkspaceTargetId ) { + if ( targetId === 'production' ) { + return __( 'Production' ); + } + + if ( targetId === 'staging' ) { + return __( 'Staging' ); + } + + return __( 'Local' ); +} + +function getMissingTargetTooltip( targetId: WorkspaceTargetId ) { + return sprintf( + // translators: %s is a workspace target label, such as "Production", "Staging", or "Local". + __( '%s target is not available for this workspace.' ), + getTargetLabel( targetId ) + ); +} + +function getSelectTargetLabel( workspace: StudioWorkspace, targetId: WorkspaceTargetId ) { + const target = workspace.targets[ targetId ]; + const label = getTargetLabel( targetId ); + + if ( ! target ) { + return sprintf( + // translators: %s is a workspace target label, such as "Production", "Staging", or "Local". + __( '%s target unavailable' ), + label + ); + } + + if ( target.kind === 'local' ) { + return target.site.running + ? sprintf( + // translators: %s is the local site name. + __( 'Select Local target: %s is running' ), + target.site.name + ) + : sprintf( + // translators: %s is the local site name. + __( 'Select Local target: %s is stopped' ), + target.site.name + ); + } + + return sprintf( + // translators: 1: workspace target label, 2: remote site URL. + __( 'Select %1$s target: %2$s' ), + label, + target.site.url + ); +} + +function getDotClassName( targetId: WorkspaceTargetId ) { + if ( targetId === 'production' ) { + return 'bg-frame-theme'; + } + + if ( targetId === 'staging' ) { + return 'bg-a8c-blue-50'; + } + + return 'bg-a8c-gray-40'; +} + +export function WorkspaceTargetSwitcher( { + workspace, + selectedTargetId, + onSelectTarget, +}: WorkspaceTargetSwitcherProps ) { + return ( +
    + { TARGET_ORDER.map( ( targetId ) => { + const target = workspace.targets[ targetId ]; + const isSelected = selectedTargetId === targetId; + const isAvailable = Boolean( target ); + const label = getTargetLabel( targetId ); + const tooltip = isAvailable ? undefined : getMissingTargetTooltip( targetId ); + + return ( + + + + ); + } ) } +
    + ); +} diff --git a/apps/studio/src/modules/workspaces/hooks/use-workspace-selection.tsx b/apps/studio/src/modules/workspaces/hooks/use-workspace-selection.tsx new file mode 100644 index 0000000000..97765af95e --- /dev/null +++ b/apps/studio/src/modules/workspaces/hooks/use-workspace-selection.tsx @@ -0,0 +1,204 @@ +import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'; +import { useSiteDetails } from 'src/hooks/use-site-details'; +import { useSidebarWorkspaces } from 'src/modules/workspaces/hooks/use-sidebar-workspaces'; +import { useWorkspaceTargetSelection } from 'src/modules/workspaces/hooks/use-workspace-target-selection'; +import { + getDefaultWorkspaceTargetId, + isWorkspaceTargetAvailable, +} from 'src/modules/workspaces/lib/target-selection'; +import { + getDefaultWorkspaceTargetTabId, + getWorkspaceTargetTabStorageKey, + isWorkspaceTargetTabId, +} from 'src/modules/workspaces/lib/workspace-tabs'; +import type { TabName } from 'src/hooks/use-content-tabs'; +import type { + LocalTarget, + RemoteTarget, + StudioWorkspace, + WorkspaceTargetId, +} from 'src/modules/workspaces/types'; + +type WorkspaceTarget = LocalTarget | RemoteTarget; + +type WorkspaceSelectionContextValue = { + enableWorkspaces: boolean; + workspaces: StudioWorkspace[]; + isLoading: boolean; + selectedWorkspace?: StudioWorkspace; + selectedWorkspaceId?: string; + selectedTargetId?: WorkspaceTargetId; + selectedTarget?: WorkspaceTarget; + getSelectedTargetId: ( workspace: StudioWorkspace ) => WorkspaceTargetId | undefined; + selectWorkspaceTarget: ( workspaceId: string, targetId: WorkspaceTargetId ) => void; + selectedTabId?: TabName; + selectWorkspaceTab: ( workspaceId: string, targetId: WorkspaceTargetId, tabId: TabName ) => void; +}; + +const WorkspaceSelectionContext = createContext< WorkspaceSelectionContextValue | undefined >( + undefined +); + +function readSavedTabId( workspaceId: string, targetId: WorkspaceTargetId ): TabName | undefined { + try { + const savedTabId = localStorage.getItem( + getWorkspaceTargetTabStorageKey( workspaceId, targetId ) + ); + if ( + savedTabId === 'overview' || + savedTabId === 'sync' || + savedTabId === 'settings' || + savedTabId === 'assistant' || + savedTabId === 'import-export' || + savedTabId === 'previews' + ) { + return savedTabId; + } + } catch { + return undefined; + } + + return undefined; +} + +function writeSavedTabId( workspaceId: string, targetId: WorkspaceTargetId, tabId: TabName ) { + try { + localStorage.setItem( getWorkspaceTargetTabStorageKey( workspaceId, targetId ), tabId ); + } catch { + // Ignore storage failures; selection still works for the current render. + } +} + +function getWorkspaceTargetKey( workspaceId: string, targetId: WorkspaceTargetId ) { + return `${ workspaceId }:${ targetId }`; +} + +export function WorkspaceSelectionProvider( { children }: { children: ReactNode } ) { + const { selectedSite, setSelectedSiteId } = useSiteDetails(); + const { enableWorkspaces, sidebarWorkspaces: workspaces, isLoading } = useSidebarWorkspaces(); + const { + selectedWorkspaceId: explicitSelectedWorkspaceId, + getSelectedTargetId, + selectWorkspaceTarget: selectTarget, + } = useWorkspaceTargetSelection( workspaces ); + const [ selectedTabs, setSelectedTabs ] = useState< Record< string, TabName > >( {} ); + const selectedSiteId = selectedSite?.id; + + const selectedWorkspace = useMemo( () => { + const explicitWorkspace = workspaces.find( + ( workspace ) => workspace.id === explicitSelectedWorkspaceId + ); + if ( explicitWorkspace ) { + return explicitWorkspace; + } + + if ( selectedSiteId ) { + const selectedSiteWorkspace = workspaces.find( + ( workspace ) => workspace.targets.local?.siteId === selectedSiteId + ); + if ( selectedSiteWorkspace ) { + return selectedSiteWorkspace; + } + } + + return workspaces[ 0 ]; + }, [ explicitSelectedWorkspaceId, selectedSiteId, workspaces ] ); + + const selectedTargetId = selectedWorkspace ? getSelectedTargetId( selectedWorkspace ) : undefined; + + const getSelectedTabId = useCallback( + ( workspaceId: string, targetId: WorkspaceTargetId ) => { + const selectedTabId = + selectedTabs[ getWorkspaceTargetKey( workspaceId, targetId ) ] ?? + readSavedTabId( workspaceId, targetId ); + + if ( selectedTabId && isWorkspaceTargetTabId( targetId, selectedTabId ) ) { + return selectedTabId; + } + + return getDefaultWorkspaceTargetTabId( targetId ); + }, + [ selectedTabs ] + ); + + const selectedTabId = + selectedWorkspace && selectedTargetId + ? getSelectedTabId( selectedWorkspace.id, selectedTargetId ) + : undefined; + + const selectWorkspaceTarget = useCallback( + ( workspaceId: string, targetId: WorkspaceTargetId ) => { + const workspace = workspaces.find( ( candidate ) => candidate.id === workspaceId ); + if ( ! workspace || ! isWorkspaceTargetAvailable( workspace, targetId ) ) { + return; + } + + selectTarget( workspaceId, targetId ); + if ( targetId === 'local' && workspace.targets.local ) { + setSelectedSiteId( workspace.targets.local.siteId ); + } + }, + [ selectTarget, setSelectedSiteId, workspaces ] + ); + + const selectWorkspaceTab = useCallback( + ( workspaceId: string, targetId: WorkspaceTargetId, tabId: TabName ) => { + if ( ! isWorkspaceTargetTabId( targetId, tabId ) ) { + return; + } + + setSelectedTabs( ( current ) => ( { + ...current, + [ getWorkspaceTargetKey( workspaceId, targetId ) ]: tabId, + } ) ); + writeSavedTabId( workspaceId, targetId, tabId ); + }, + [] + ); + + const value = useMemo< WorkspaceSelectionContextValue >( () => { + const fallbackTargetId = + selectedWorkspace && ! selectedTargetId + ? getDefaultWorkspaceTargetId( selectedWorkspace ) + : selectedTargetId; + + return { + enableWorkspaces, + workspaces, + isLoading, + selectedWorkspace, + selectedWorkspaceId: selectedWorkspace?.id, + selectedTargetId: fallbackTargetId, + selectedTarget: fallbackTargetId ? selectedWorkspace?.targets[ fallbackTargetId ] : undefined, + getSelectedTargetId, + selectWorkspaceTarget, + selectedTabId, + selectWorkspaceTab, + }; + }, [ + enableWorkspaces, + getSelectedTargetId, + isLoading, + selectWorkspaceTab, + selectWorkspaceTarget, + selectedTabId, + selectedTargetId, + selectedWorkspace, + workspaces, + ] ); + + return ( + + { children } + + ); +} + +export function useWorkspaceSelection() { + const context = useContext( WorkspaceSelectionContext ); + if ( ! context ) { + throw new Error( 'useWorkspaceSelection must be used within a WorkspaceSelectionProvider' ); + } + + return context; +} diff --git a/apps/studio/src/modules/workspaces/index.ts b/apps/studio/src/modules/workspaces/index.ts index d186230039..2bbd11f975 100644 --- a/apps/studio/src/modules/workspaces/index.ts +++ b/apps/studio/src/modules/workspaces/index.ts @@ -9,7 +9,19 @@ export { isWorkspaceTargetAvailable, } from 'src/modules/workspaces/lib/target-selection'; export { useSidebarWorkspaces } from 'src/modules/workspaces/hooks/use-sidebar-workspaces'; +export { + useWorkspaceSelection, + WorkspaceSelectionProvider, +} from 'src/modules/workspaces/hooks/use-workspace-selection'; export { useWorkspaceTargetSelection } from 'src/modules/workspaces/hooks/use-workspace-target-selection'; +export { + getDefaultWorkspaceTargetTabId, + getWorkspaceTargetTabIds, + getWorkspaceTargetTabStorageKey, + isWorkspaceTargetTabId, + LOCAL_WORKSPACE_TAB_IDS, + REMOTE_WORKSPACE_TAB_IDS, +} from 'src/modules/workspaces/lib/workspace-tabs'; export type { BuildStudioWorkspacesInput, LocalTarget, diff --git a/apps/studio/src/modules/workspaces/lib/workspace-tabs.ts b/apps/studio/src/modules/workspaces/lib/workspace-tabs.ts new file mode 100644 index 0000000000..dcd0e71cc6 --- /dev/null +++ b/apps/studio/src/modules/workspaces/lib/workspace-tabs.ts @@ -0,0 +1,34 @@ +import type { TabName } from 'src/hooks/use-content-tabs'; +import type { WorkspaceTargetId } from 'src/modules/workspaces/types'; + +const WORKSPACE_TARGET_TAB_STORAGE_PREFIX = 'studio-workspace-target-tab:'; + +export const LOCAL_WORKSPACE_TAB_IDS: TabName[] = [ + 'overview', + 'sync', + 'previews', + 'import-export', + 'settings', + 'assistant', +]; + +export const REMOTE_WORKSPACE_TAB_IDS: TabName[] = [ 'assistant', 'sync', 'settings' ]; + +export function getWorkspaceTargetTabIds( targetId: WorkspaceTargetId ): TabName[] { + return targetId === 'local' ? LOCAL_WORKSPACE_TAB_IDS : REMOTE_WORKSPACE_TAB_IDS; +} + +export function isWorkspaceTargetTabId( targetId: WorkspaceTargetId, tabId: TabName ) { + return getWorkspaceTargetTabIds( targetId ).includes( tabId ); +} + +export function getDefaultWorkspaceTargetTabId( targetId: WorkspaceTargetId ): TabName { + return targetId === 'local' ? 'overview' : 'assistant'; +} + +export function getWorkspaceTargetTabStorageKey( + workspaceId: string, + targetId: WorkspaceTargetId +) { + return `${ WORKSPACE_TARGET_TAB_STORAGE_PREFIX }${ workspaceId }:${ targetId }`; +} From f50ad0cd168f8046854f81d3a7ef46c5fb3f9a00 Mon Sep 17 00:00:00 2001 From: dereksmart Date: Fri, 15 May 2026 19:08:38 -0400 Subject: [PATCH 4/7] Add Dolly assistant for workspace targets Replace the remote workspace Assistant placeholder with a Dolly-backed Agenttic chat while keeping Local targets on the existing ContentTabAssistant path. Add target-scoped Dolly conversation/session state keyed by workspace, target, site, and conversation. This includes server history hydration, active turn tracking, target unread/thinking indicators, and preview/refresh abilities only for the selected remote target. Add image upload support for Dolly chats using the selected target site media endpoint. Chat images render inline only for local blob/data previews or URLs whose host matches the active target site; other image links continue to open in preview. Pin @automattic/agenttic-client and @automattic/agenttic-ui to 0.1.63, and add the PostCSS layer compatibility shim needed for Agenttic UI CSS. Tradeoffs and risks: this slice intentionally uses Agenttic client/UI instead of adapting the existing local assistant UI because Dolly uses different transport, session, and history semantics. The main review risks are the new dependency and lockfile surface, the CSS/PostCSS compatibility shim, image CSP/rendering behavior, target-scoped session isolation, server history merge behavior, and hidden-target turn completion. Mitigations are exact dependency pins, keeping Local on the existing assistant path, limiting Dolly frontend tools to selected-target preview/open/refresh behavior, gating inline remote images to the active site hostname, and adding focused target-isolation tests. Verification: eslint --fix on modified source files, focused Dolly/media tests, existing workspace regression tests, local assistant regression test, studio-app and workspace typechecks, scripts typecheck, electron-vite build, and git diff --check. --- apps/studio/index.html | 2 +- apps/studio/package.json | 2 + apps/studio/postcss.config.js | 30 +- apps/studio/src/components/tests/app.test.tsx | 3 + .../tests/site-content-tabs.test.tsx | 22 + apps/studio/src/index.ts | 2 +- .../components/workspace-content-shell.tsx | 31 +- .../workspace-dolly-assistant.test.tsx | 751 +++++++++ .../components/workspace-dolly-assistant.tsx | 1422 +++++++++++++++++ .../workspace-target-indicators.tsx | 47 +- .../src/modules/workspaces/lib/dolly/api.ts | 460 ++++++ .../workspaces/lib/dolly/media.test.ts | 74 + .../modules/workspaces/lib/dolly/media.tsx | 300 ++++ .../modules/workspaces/lib/dolly/preview.ts | 358 +++++ .../modules/workspaces/lib/dolly/session.ts | 462 ++++++ .../modules/workspaces/lib/dolly/transport.ts | 211 +++ .../src/modules/workspaces/lib/dolly/turns.ts | 149 ++ .../src/modules/workspaces/lib/dolly/types.ts | 133 ++ .../src/modules/workspaces/lib/dolly/utils.ts | 96 ++ package-lock.json | 1148 ++++++++++++- 20 files changed, 5645 insertions(+), 58 deletions(-) create mode 100644 apps/studio/src/modules/workspaces/components/workspace-dolly-assistant.test.tsx create mode 100644 apps/studio/src/modules/workspaces/components/workspace-dolly-assistant.tsx create mode 100644 apps/studio/src/modules/workspaces/lib/dolly/api.ts create mode 100644 apps/studio/src/modules/workspaces/lib/dolly/media.test.ts create mode 100644 apps/studio/src/modules/workspaces/lib/dolly/media.tsx create mode 100644 apps/studio/src/modules/workspaces/lib/dolly/preview.ts create mode 100644 apps/studio/src/modules/workspaces/lib/dolly/session.ts create mode 100644 apps/studio/src/modules/workspaces/lib/dolly/transport.ts create mode 100644 apps/studio/src/modules/workspaces/lib/dolly/turns.ts create mode 100644 apps/studio/src/modules/workspaces/lib/dolly/types.ts create mode 100644 apps/studio/src/modules/workspaces/lib/dolly/utils.ts diff --git a/apps/studio/index.html b/apps/studio/index.html index b088d982ac..0f47adb5b8 100644 --- a/apps/studio/index.html +++ b/apps/studio/index.html @@ -3,7 +3,7 @@ - + WordPress Studio diff --git a/apps/studio/package.json b/apps/studio/package.json index 6f5694af4a..80e4a2fdd1 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -30,6 +30,8 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { + "@automattic/agenttic-client": "0.1.63", + "@automattic/agenttic-ui": "0.1.63", "@automattic/generate-password": "^0.1.0", "@automattic/interpolate-components": "^1.2.1", "@formatjs/intl-locale": "^3.4.5", diff --git a/apps/studio/postcss.config.js b/apps/studio/postcss.config.js index 4b65759eb7..07e34ba294 100644 --- a/apps/studio/postcss.config.js +++ b/apps/studio/postcss.config.js @@ -1,9 +1,31 @@ const path = require( 'path' ); +const tailwindcss = require( 'tailwindcss' ); + +const preserveAgentticBaseLayer = { + postcssPlugin: 'preserve-agenttic-base-layer', + Once( root ) { + const inputPath = root.source?.input.file ?? ''; + const isAgentticCss = inputPath.includes( + `${ path.sep }@automattic${ path.sep }agenttic-ui${ path.sep }` + ); + + if ( ! isAgentticCss ) { + return; + } + + root.walkAtRules( 'layer', ( rule ) => { + if ( rule.params === 'base' && rule.nodes ) { + rule.params = 'agenttic-base'; + } + } ); + }, +}; module.exports = { - plugins: { - tailwindcss: { + plugins: [ + preserveAgentticBaseLayer, + tailwindcss( { config: path.join( __dirname, 'tailwind.config.js' ), - }, - }, + } ), + ], }; diff --git a/apps/studio/src/components/tests/app.test.tsx b/apps/studio/src/components/tests/app.test.tsx index 7374cd1f00..0cb38d7349 100644 --- a/apps/studio/src/components/tests/app.test.tsx +++ b/apps/studio/src/components/tests/app.test.tsx @@ -25,6 +25,9 @@ const featureFlagsMock = vi.hoisted( () => ( { const useGetWpComSitesQueryMock = vi.hoisted( () => vi.fn() ); vi.mock( 'src/index.css', () => ( {} ) ); +vi.mock( 'src/modules/workspaces/components/workspace-dolly-assistant', () => ( { + WorkspaceDollyAssistant: () => null, +} ) ); vi.mock( 'src/components/dot-grid', () => ( { DotGrid: () => null, } ) ); diff --git a/apps/studio/src/components/tests/site-content-tabs.test.tsx b/apps/studio/src/components/tests/site-content-tabs.test.tsx index 3538e46691..3642f1504d 100644 --- a/apps/studio/src/components/tests/site-content-tabs.test.tsx +++ b/apps/studio/src/components/tests/site-content-tabs.test.tsx @@ -27,6 +27,14 @@ const selectedSite: SiteDetails = { }; vi.mock( 'src/hooks/use-site-details' ); +vi.mock( 'src/components/content-tab-assistant', () => ( { + ContentTabAssistant: ( { selectedSite }: { selectedSite: SiteDetails } ) => ( +
    { selectedSite.name }
    + ), +} ) ); +vi.mock( 'src/modules/workspaces/components/workspace-dolly-assistant', () => ( { + WorkspaceDollyAssistant: () =>
    , +} ) ); vi.mock( 'src/hooks/use-feature-flags', () => ( { useFeatureFlags: () => featureFlagsMock, } ) ); @@ -226,6 +234,20 @@ describe( 'SiteContentTabs', () => { ); } ); + it( 'keeps the local workspace Assistant tab on the existing local assistant', async () => { + const user = userEvent.setup(); + featureFlagsMock.enableWorkspaces = true; + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn(), + } ); + + await act( async () => renderWithProvider( ) ); + await user.click( screen.getByRole( 'tab', { name: 'Assistant' } ) ); + + expect( screen.getByTestId( 'local-content-tab-assistant' ) ).toHaveTextContent( 'Test Site' ); + expect( screen.queryByTestId( 'workspace-dolly-assistant' ) ).not.toBeInTheDocument(); + } ); + it( 'renders remote Production targets with remote tabs only', async () => { featureFlagsMock.enableWorkspaces = true; mockWpcomSitesQuery( [ createSyncSite( { id: 101, name: 'Remote Only' } ) ] ); diff --git a/apps/studio/src/index.ts b/apps/studio/src/index.ts index e7bc8becfb..d4f2d8fa4e 100644 --- a/apps/studio/src/index.ts +++ b/apps/studio/src/index.ts @@ -305,7 +305,7 @@ async function appBoot() { const basePolicies = [ "default-src 'self'", // Allow resources from these domains "script-src-attr 'none'", - "img-src 'self' https://*.gravatar.com https://*.wp.com https://blueprintlibrary.wordpress.com https://blueprintslibraryv2.wpcomstaging.com https://wordpress.github.io https://raw.githubusercontent.com data:", + "img-src 'self' https: data: blob:", "style-src 'self' 'unsafe-inline'", // unsafe-inline used by tailwindcss in development, and also in production after the app rename "script-src 'self' 'wasm-unsafe-eval'", // allow WebAssembly to compile and instantiate // Site preview uses `` to host local WordPress sites diff --git a/apps/studio/src/modules/workspaces/components/workspace-content-shell.tsx b/apps/studio/src/modules/workspaces/components/workspace-content-shell.tsx index 0a4f44b5e2..01a64edc7d 100644 --- a/apps/studio/src/modules/workspaces/components/workspace-content-shell.tsx +++ b/apps/studio/src/modules/workspaces/components/workspace-content-shell.tsx @@ -18,6 +18,7 @@ import { getWorkspaceTargetTabIds, useWorkspaceSelection, } from 'src/modules/workspaces'; +import { WorkspaceDollyAssistant } from 'src/modules/workspaces/components/workspace-dolly-assistant'; import { WorkspaceHeader } from 'src/modules/workspaces/components/workspace-header'; import { createDefaultWorkspacePreviewState, @@ -44,17 +45,6 @@ function EmptyWorkspaceSelection() { ); } -function RemoteAssistantPlaceholder( { target }: { target: RemoteTarget } ) { - return ( -
    -
    -

    { __( 'Assistant' ) }

    -

    { target.site.name }

    -
    -
    - ); -} - function RemoteSyncPlaceholder( { workspace, target, @@ -137,11 +127,26 @@ function renderRemoteTabContent( { workspace, target, name, + previewState, + onUpdatePreviewState, }: { workspace: StudioWorkspace; target: RemoteTarget; name: TabName; + previewState: WorkspacePreviewState; + onUpdatePreviewState: ( state: WorkspacePreviewState ) => void; } ) { + if ( name === 'assistant' ) { + return ( + + ); + } + if ( name === 'sync' ) { return ; } @@ -150,7 +155,7 @@ function renderRemoteTabContent( { return ; } - return ; + return null; } function resolveLocalPreviewBaseUrl( site: SiteDetails ) { @@ -354,6 +359,8 @@ export function WorkspaceContentShell() { workspace: selectedWorkspace, target: remoteTarget, name: name as TabName, + previewState, + onUpdatePreviewState: updatePreviewState, } ) }
    ) } diff --git a/apps/studio/src/modules/workspaces/components/workspace-dolly-assistant.test.tsx b/apps/studio/src/modules/workspaces/components/workspace-dolly-assistant.test.tsx new file mode 100644 index 0000000000..da70407dc4 --- /dev/null +++ b/apps/studio/src/modules/workspaces/components/workspace-dolly-assistant.test.tsx @@ -0,0 +1,751 @@ +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { vi } from 'vitest'; +import { AuthContext, type AuthContextType } from 'src/components/auth-provider'; +import { WorkspaceDollyAssistant } from 'src/modules/workspaces/components/workspace-dolly-assistant'; +import { + createDefaultWorkspacePreviewState, + type WorkspacePreviewState, +} from 'src/modules/workspaces/components/workspace-preview'; +import { hydrateWorkspaceDollyConversationStates } from 'src/modules/workspaces/lib/dolly/api'; +import { + clearWorkspaceDollyAssistantStateCacheForTests, + getWorkspaceDollyConversationState, + mergeWorkspaceDollyConversationState, +} from 'src/modules/workspaces/lib/dolly/session'; +import type { SyncSite } from '@studio/common/types/sync'; +import type { ReactNode } from 'react'; +import type { RemoteTarget, RemoteTargetId, StudioWorkspace } from 'src/modules/workspaces/types'; +import type { WPCOM } from 'wpcom/types'; + +vi.mock( '@automattic/agenttic-ui', async () => { + const React = await vi.importActual< typeof import('react') >( 'react' ); + + type MockAgentMessage = { + id: string; + content: Array< { + text?: string; + component?: React.ComponentType< { images?: Array< { name: string; url: string } > } >; + componentProps?: { images?: Array< { name: string; url: string } > }; + } >; + }; + type MockAction = { + id: string; + icon: ReactNode; + onClick: ( event?: React.MouseEvent< HTMLButtonElement > ) => void; + disabled?: boolean; + 'aria-label': string; + }; + type MockNotice = { + message?: string; + action?: { + label: string; + onClick: () => void; + }; + }; + type MockContainerProps = { + children?: ReactNode; + className?: string; + messages: MockAgentMessage[]; + isProcessing: boolean; + onSubmit: ( value: string ) => void; + onStop: () => void; + inputValue: string; + onInputChange: ( value: string ) => void; + placeholder?: string; + notice?: MockNotice; + }; + type MockChildrenProps = { + children?: ReactNode; + className?: string; + }; + type MockInputProps = { + disabled?: boolean; + customActions?: MockAction[]; + }; + type MockImageUploaderHandle = { + openFileDialog: () => void; + }; + type MockImageUploaderProps = { + images: Array< { id: string; name?: string; url: string } >; + onFilesSelected: ( files: File[] ) => void; + onRemoveImage: ( image: { id: string; name?: string; url: string } ) => void; + acceptedFileTypes?: string[]; + }; + + const MockAgentUIContext = React.createContext< MockContainerProps | undefined >( undefined ); + const useMockAgentUIContext = () => { + const context = React.useContext( MockAgentUIContext ); + if ( ! context ) { + throw new Error( 'Missing mocked AgentUI context.' ); + } + return context; + }; + + const Container = ( props: MockContainerProps ) => + React.createElement( + MockAgentUIContext.Provider, + { value: props }, + React.createElement( 'div', { className: props.className }, props.children ) + ); + + const ConversationView = React.forwardRef< HTMLDivElement, MockChildrenProps >( + ( { children, className }, ref ) => React.createElement( 'div', { ref, className }, children ) + ); + + const Messages = () => { + const { messages, isProcessing } = useMockAgentUIContext(); + return React.createElement( + 'div', + { 'data-slot': 'messages' }, + ...messages.map( ( message ) => + React.createElement( + 'div', + { key: message.id }, + message.content.map( ( part, index ) => { + if ( part.component ) { + return React.createElement( part.component, { + key: index, + ...( part.componentProps ?? {} ), + } ); + } + return part.text ?? ''; + } ) + ) + ), + isProcessing ? React.createElement( 'div', { key: 'thinking' }, 'Thinking...' ) : null + ); + }; + + const Footer = ( { children, className }: MockChildrenProps ) => + React.createElement( 'div', { className }, children ); + + const Notice = () => { + const { notice } = useMockAgentUIContext(); + if ( ! notice?.message ) { + return null; + } + + return React.createElement( + 'div', + null, + notice.message, + notice.action + ? React.createElement( + 'button', + { type: 'button', onClick: notice.action.onClick }, + notice.action.label + ) + : null + ); + }; + + const Input = ( { disabled, customActions = [] }: MockInputProps ) => { + const { inputValue, isProcessing, onInputChange, onStop, onSubmit, placeholder } = + useMockAgentUIContext(); + const isSendDisabled = disabled || ( ! inputValue.trim() && ! isProcessing ); + + return React.createElement( + 'div', + null, + React.createElement( 'textarea', { + value: inputValue, + placeholder, + onChange: ( event: { target: { value: string } } ) => onInputChange( event.target.value ), + onKeyDown: ( event: KeyboardEvent ) => { + if ( event.key === 'Enter' && ! isSendDisabled ) { + event.preventDefault(); + onSubmit( inputValue ); + } + }, + } ), + ...customActions.map( ( action ) => + React.createElement( + 'button', + { + key: action.id, + type: 'button', + 'aria-label': action[ 'aria-label' ], + disabled: action.disabled, + onClick: action.onClick, + }, + action[ 'aria-label' ] + ) + ), + React.createElement( + 'button', + { + type: 'button', + 'aria-label': isProcessing ? 'Stop processing' : 'Send message', + disabled: isSendDisabled, + onClick: () => ( isProcessing ? onStop() : onSubmit( inputValue ) ), + }, + isProcessing ? 'Stop' : 'Send' + ) + ); + }; + + const ImageUploader = React.forwardRef< MockImageUploaderHandle, MockImageUploaderProps >( + ( { images, onFilesSelected, onRemoveImage, acceptedFileTypes }, ref ) => { + const inputRef = React.useRef< HTMLInputElement >( null ); + React.useImperativeHandle( ref, () => ( { + openFileDialog: () => inputRef.current?.click(), + } ) ); + + return React.createElement( + 'div', + null, + React.createElement( 'input', { + ref: inputRef, + type: 'file', + 'aria-label': 'Image upload input', + accept: acceptedFileTypes?.join( ',' ), + onChange: ( event: { target: HTMLInputElement } ) => { + onFilesSelected( Array.from( event.target.files ?? [] ) ); + }, + } ), + ...images.map( ( image ) => + React.createElement( + 'button', + { + key: image.id, + type: 'button', + onClick: () => onRemoveImage( image ), + }, + image.name ?? image.id + ) + ) + ); + } + ); + + return { + AgentUI: { + Container, + ConversationView, + Messages, + Footer, + Notice, + Input, + }, + ImageUploader, + createMessageRenderer: vi.fn(), + }; +} ); + +vi.mock( 'src/hooks/use-offline', () => ( { + useOffline: () => false, +} ) ); + +vi.mock( 'src/lib/get-ipc-api', () => ( { + getIpcApi: () => ( { + getAuthenticationToken: vi.fn().mockResolvedValue( { + accessToken: 'test-token', + expiresIn: 1209600, + expirationTime: Date.now() + 1209600000, + id: 1, + email: 'test@example.com', + displayName: 'Test User', + } ), + } ), +} ) ); + +const createSyncSite = ( overrides: Partial< SyncSite > = {} ): SyncSite => ( { + id: 101, + localSiteId: '', + name: 'Production Site', + url: 'https://production.example', + isStaging: false, + isPressable: false, + syncSupport: 'syncable', + lastPullTimestamp: null, + lastPushTimestamp: null, + ...overrides, +} ); + +const productionSite = createSyncSite( { + id: 101, + name: 'Production Site', + url: 'https://production.example', + stagingSiteIds: [ 202 ], +} ); + +const stagingSite = createSyncSite( { + id: 202, + name: 'Staging Site', + url: 'https://staging.example', + isStaging: true, + productionSiteId: 101, +} ); + +const createRemoteTarget = ( id: RemoteTargetId, site: SyncSite ): RemoteTarget => ( { + id, + kind: 'remote', + siteId: site.id, + site, +} ); + +const productionTarget = createRemoteTarget( 'production', productionSite ); +const stagingTarget = createRemoteTarget( 'staging', stagingSite ); + +const workspace: StudioWorkspace = { + id: 'studio-workspace:wpcom:101', + name: 'Production Site', + targets: { + production: productionTarget, + staging: stagingTarget, + }, + syncLinks: [], + activity: { status: 'idle' }, +}; + +type DollyFetchHandler = ( args: { + body: { + method?: string; + params?: { + id?: string; + sessionId?: string; + message?: { + parts?: Array< { + type?: string; + text?: string; + data?: Record< string, unknown >; + } >; + }; + }; + }; + init: RequestInit; + url: string; + callIndex: number; +} ) => unknown | Promise< unknown >; + +const createDollyFetchResponse = ( data: unknown, requestBody?: { method?: string } ) => { + if ( requestBody?.method === 'message/stream' ) { + const encoder = new TextEncoder(); + const stream = new ReadableStream( { + start( controller ) { + controller.enqueue( encoder.encode( `data: ${ JSON.stringify( data ) }\n\n` ) ); + controller.close(); + }, + } ); + + return new Response( stream, { + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + }, + } ); + } + + return new Response( JSON.stringify( data ), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + } ); +}; + +const mockDollyFetch = ( handler: DollyFetchHandler ) => { + const requestBodies: Array< Parameters< DollyFetchHandler >[ 0 ][ 'body' ] > = []; + const requestUrls: string[] = []; + const fetchMock = vi.fn( async ( input: RequestInfo | URL, init?: RequestInit ) => { + const url = String( input ); + const body = JSON.parse( String( init?.body ?? '{}' ) ); + const callIndex = requestBodies.length; + requestBodies.push( body ); + requestUrls.push( url ); + + return createDollyFetchResponse( + await handler( { + body, + init: init ?? {}, + url, + callIndex, + } ), + body + ); + } ); + + vi.stubGlobal( 'fetch', fetchMock ); + + return { + fetchMock, + requestBodies, + requestUrls, + }; +}; + +const createDollyResponse = ( text: string, sessionId: string, taskId = 'task-1' ) => ( { + jsonrpc: '2.0', + id: 'rpc-1', + result: { + id: taskId, + sessionId, + status: { + state: 'completed', + message: { + kind: 'message', + messageId: `${ taskId }-message`, + role: 'agent', + parts: [ + { + type: 'text', + text, + }, + ], + }, + }, + }, +} ); + +const unauthenticatedClient = { req: {} } as unknown as WPCOM; + +const renderDollyAssistant = ( { + target = productionTarget, + client = unauthenticatedClient, + previewState = createDefaultWorkspacePreviewState(), + onUpdatePreviewState = vi.fn(), +}: { + target?: RemoteTarget; + client?: WPCOM; + previewState?: WorkspacePreviewState; + onUpdatePreviewState?: ( state: WorkspacePreviewState ) => void; +} = {} ) => { + const authContextValue: AuthContextType = { + client, + isAuthenticated: true, + authenticate: vi.fn(), + logout: vi.fn().mockResolvedValue( undefined ), + }; + + return render( + + + + ); +}; + +const rerenderDollyAssistant = ( + rerender: ReturnType< typeof render >[ 'rerender' ], + { + target, + client = unauthenticatedClient, + previewState = createDefaultWorkspacePreviewState(), + onUpdatePreviewState = vi.fn(), + }: { + target: RemoteTarget; + client?: WPCOM; + previewState?: WorkspacePreviewState; + onUpdatePreviewState?: ( state: WorkspacePreviewState ) => void; + } +) => { + const authContextValue: AuthContextType = { + client, + isAuthenticated: true, + authenticate: vi.fn(), + logout: vi.fn().mockResolvedValue( undefined ), + }; + + rerender( + + + + ); +}; + +const getInput = () => screen.getByRole( 'textbox' ); + +const getChatMessageText = ( text: string | RegExp ) => + screen.getAllByText( text ).find( ( element ) => element.closest( '[data-slot="messages"]' ) ) ?? + screen.getByText( text ); + +const queryChatMessageText = ( text: string | RegExp ) => + screen + .queryAllByText( text ) + .find( ( element ) => element.closest( '[data-slot="messages"]' ) ) ?? null; + +describe( 'WorkspaceDollyAssistant', () => { + beforeEach( () => { + vi.clearAllMocks(); + clearWorkspaceDollyAssistantStateCacheForTests(); + localStorage.clear(); + window.HTMLElement.prototype.scrollTo = vi.fn(); + Object.defineProperty( URL, 'createObjectURL', { + configurable: true, + value: vi.fn( () => 'blob:workspace-dolly-image' ), + } ); + Object.defineProperty( URL, 'revokeObjectURL', { + configurable: true, + value: vi.fn(), + } ); + vi.stubGlobal( + 'fetch', + vi.fn( async () => { + throw new Error( 'Unexpected fetch request' ); + } ) + ); + } ); + + afterAll( () => { + vi.unstubAllGlobals(); + } ); + + it( 'sends Production and Staging chats to different Dolly site endpoints with separate sessions', async () => { + const { requestBodies, requestUrls } = mockDollyFetch( ( { callIndex } ) => + createDollyResponse( + callIndex === 0 ? 'Production response' : 'Staging response', + callIndex === 0 ? 'session-production' : 'session-staging', + callIndex === 0 ? 'task-production' : 'task-staging' + ) + ); + const { rerender } = renderDollyAssistant( { target: productionTarget } ); + + fireEvent.change( getInput(), { target: { value: 'Hello production' } } ); + fireEvent.click( screen.getByRole( 'button', { name: 'Send message' } ) ); + + await waitFor( () => { + expect( getChatMessageText( 'Production response' ) ).toBeVisible(); + } ); + + rerenderDollyAssistant( rerender, { target: stagingTarget } ); + fireEvent.change( getInput(), { target: { value: 'Hello staging' } } ); + fireEvent.click( screen.getByRole( 'button', { name: 'Send message' } ) ); + + await waitFor( () => { + expect( getChatMessageText( 'Staging response' ) ).toBeVisible(); + } ); + + expect( requestUrls ).toEqual( [ + 'https://public-api.wordpress.com/wpcom/v2/sites/101/ai/agent/dolly', + 'https://public-api.wordpress.com/wpcom/v2/sites/202/ai/agent/dolly', + ] ); + expect( requestBodies[ 0 ].params?.sessionId ).toBe( requestBodies[ 0 ].params?.id ); + expect( requestBodies[ 1 ].params?.sessionId ).toBe( requestBodies[ 1 ].params?.id ); + expect( requestBodies[ 1 ].params?.sessionId ).not.toBe( requestBodies[ 0 ].params?.sessionId ); + } ); + + it( 'uploads pending images and sends them to Dolly through input actions', async () => { + const dollyRequestBodies: Array< Record< string, unknown > > = []; + const mediaRequests: string[] = []; + const fetchMock = vi.fn( async ( input: RequestInfo | URL, init?: RequestInit ) => { + const url = String( input ); + + if ( url.includes( '/rest/v1.1/sites/101/media/new' ) ) { + mediaRequests.push( url ); + expect( init?.body ).toBeInstanceOf( FormData ); + + return new Response( + JSON.stringify( { + media: [ + { + ID: 777, + URL: 'https://cdn.example/site-image.png', + mime_type: 'image/png', + file: 'site-image.png', + title: 'site-image', + }, + ], + } ), + { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + } + ); + } + + const body = JSON.parse( String( init?.body ?? '{}' ) ); + dollyRequestBodies.push( body ); + return createDollyFetchResponse( + createDollyResponse( 'Image response', 'session-image', 'task-image' ), + body + ); + } ); + vi.stubGlobal( 'fetch', fetchMock ); + renderDollyAssistant( { target: productionTarget } ); + + expect( screen.getByRole( 'button', { name: 'Upload image' } ) ).toBeVisible(); + + const imageFile = new File( [ 'image-bytes' ], 'site-image.png', { type: 'image/png' } ); + fireEvent.change( screen.getByLabelText( 'Image upload input' ), { + target: { files: [ imageFile ] }, + } ); + + await waitFor( () => { + expect( screen.getByText( 'site-image.png' ) ).toBeVisible(); + } ); + + fireEvent.change( getInput(), { target: { value: 'What is in this image?' } } ); + fireEvent.click( screen.getByRole( 'button', { name: 'Send message' } ) ); + + await waitFor( () => { + expect( getChatMessageText( 'Image response' ) ).toBeVisible(); + } ); + + expect( mediaRequests ).toEqual( [ + 'https://public-api.wordpress.com/rest/v1.1/sites/101/media/new', + ] ); + expect( JSON.stringify( dollyRequestBodies[ 0 ] ) ).toContain( + 'https://cdn.example/site-image.png' + ); + expect( screen.getByRole( 'button', { name: 'Chat options' } ) ).toBeVisible(); + } ); + + it( 'does not show another target messages or draft input when targets switch', async () => { + const { rerender } = renderDollyAssistant( { target: productionTarget } ); + + fireEvent.change( getInput(), { target: { value: 'Production draft' } } ); + expect( getInput() ).toHaveValue( 'Production draft' ); + + rerenderDollyAssistant( rerender, { target: stagingTarget } ); + + await waitFor( () => { + expect( getInput() ).toHaveValue( '' ); + } ); + expect( queryChatMessageText( 'Production draft' ) ).not.toBeInTheDocument(); + + fireEvent.change( getInput(), { target: { value: 'Staging draft' } } ); + expect( getInput() ).toHaveValue( 'Staging draft' ); + + rerenderDollyAssistant( rerender, { target: productionTarget } ); + + await waitFor( () => { + expect( getInput() ).toHaveValue( 'Production draft' ); + } ); + expect( queryChatMessageText( 'Staging draft' ) ).not.toBeInTheDocument(); + } ); + + it( 'keeps a hidden target turn alive and restores its response when selected again', async () => { + let firstRequestSignal: AbortSignal | undefined; + let resolveFirstRequest: ( value: unknown ) => void = () => {}; + mockDollyFetch( ( { init } ) => { + firstRequestSignal = init.signal as AbortSignal; + return new Promise( ( resolve ) => { + resolveFirstRequest = resolve; + } ); + } ); + const { rerender } = renderDollyAssistant( { target: productionTarget } ); + + fireEvent.change( getInput(), { target: { value: 'Keep working' } } ); + fireEvent.click( screen.getByRole( 'button', { name: 'Send message' } ) ); + + await screen.findByRole( 'button', { name: 'Stop processing' } ); + + rerenderDollyAssistant( rerender, { target: stagingTarget } ); + + expect( firstRequestSignal?.aborted ).toBe( false ); + + await act( async () => { + resolveFirstRequest( + createDollyResponse( + 'Production finished while hidden', + 'session-production-hidden', + 'task-production-hidden' + ) + ); + } ); + + expect( queryChatMessageText( 'Production finished while hidden' ) ).not.toBeInTheDocument(); + + rerenderDollyAssistant( rerender, { target: productionTarget } ); + + await waitFor( () => { + expect( getChatMessageText( 'Production finished while hidden' ) ).toBeVisible(); + } ); + } ); + + it( 'hydrates server conversations into the matching workspace target only', async () => { + const client = { + req: { + get: vi.fn( ( { path }, callback ) => { + if ( path.startsWith( '/ai/chats/wpcom-agent-dolly' ) ) { + callback( null, [ + { + chat_id: 301, + session_id: 'server-production-session', + site_id: 101, + created_at: '2026-05-14 13:00:00', + }, + { + chat_id: 302, + session_id: 'server-staging-session', + site_id: 202, + created_at: '2026-05-14 14:00:00', + }, + ] ); + return; + } + + if ( path.startsWith( '/ai/chat/wpcom-agent-dolly/301' ) ) { + callback( null, { + chat_id: 301, + session_id: 'server-production-session', + site_id: 101, + messages: [ + { + role: 'user', + content: 'Production history question', + created_at: '2026-05-14 13:00:00', + }, + { + role: 'assistant', + content: 'Production history answer', + created_at: '2026-05-14 13:01:00', + }, + ], + } ); + return; + } + + throw new Error( `Unexpected history path: ${ path }` ); + } ), + }, + } as unknown as WPCOM; + + const hydratedProductionStates = await hydrateWorkspaceDollyConversationStates( client, { + workspaceId: workspace.id, + targetId: 'production', + site: productionSite, + } ); + + hydratedProductionStates.forEach( ( conversationState ) => { + mergeWorkspaceDollyConversationState( conversationState, { selectIfEmpty: true } ); + } ); + + const productionConversation = getWorkspaceDollyConversationState( { + workspaceId: workspace.id, + targetId: 'production', + site: productionSite, + } ); + const stagingConversation = getWorkspaceDollyConversationState( { + workspaceId: workspace.id, + targetId: 'staging', + site: stagingSite, + } ); + + expect( productionConversation.messages.map( ( message ) => message.content ) ).toEqual( [ + 'Production history question', + 'Production history answer', + ] ); + expect( productionConversation.sessionId ).toBe( 'server-production-session' ); + expect( stagingConversation.messages ).toEqual( [] ); + expect( client.req.get ).not.toHaveBeenCalledWith( + expect.objectContaining( { + path: expect.stringContaining( '/302' ), + } ), + expect.any( Function ) + ); + } ); +} ); diff --git a/apps/studio/src/modules/workspaces/components/workspace-dolly-assistant.tsx b/apps/studio/src/modules/workspaces/components/workspace-dolly-assistant.tsx new file mode 100644 index 0000000000..1c89196e6a --- /dev/null +++ b/apps/studio/src/modules/workspaces/components/workspace-dolly-assistant.tsx @@ -0,0 +1,1422 @@ +import { AgentUI, ImageUploader, createMessageRenderer } from '@automattic/agenttic-ui'; +import { Popover } from '@wordpress/components'; +import { __, sprintf } from '@wordpress/i18n'; +import { chevronDown, Icon, image as imageIcon, moreVertical, plus, trash } from '@wordpress/icons'; +import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from 'react'; +import { ArrowIcon } from 'src/components/arrow-icon'; +import Button from 'src/components/button'; +import { ChatMessage } from 'src/components/chat-message'; +import offlineIcon from 'src/components/offline-icon'; +import { LIMIT_OF_PROMPTS_PER_USER } from 'src/constants'; +import { useAuth } from 'src/hooks/use-auth'; +import { useOffline } from 'src/hooks/use-offline'; +import { cx } from 'src/lib/cx'; +import { hydrateWorkspaceDollyConversationStates } from 'src/modules/workspaces/lib/dolly/api'; +import { + WORKSPACE_DOLLY_IMAGE_PREVIEW_CLASS_NAME, + WORKSPACE_DOLLY_IMAGE_PREVIEW_STYLE, + WorkspaceDollyOptimisticImages, + createWorkspaceDollyImagePrompt, + createWorkspaceDollyPendingVisibleImages, + createWorkspaceDollyVisibleMessage, + isWorkspaceDollyRenderableImageLinkUrl, + isWorkspaceDollyRenderableImageUrl, + readWorkspaceDollyFileAsDataUrl, + revokeWorkspaceDollyPendingImageUrls, + uploadWorkspaceDollyImages, +} from 'src/modules/workspaces/lib/dolly/media'; +import { + createWorkspaceDollyPreviewAbilities, + createWorkspaceDollyPreviewContext, + createWorkspaceDollySiteAssociationContext, + getNextWorkspaceDollyPreviewState, + normalizeWorkspaceDollyPreviewUrl, +} from 'src/modules/workspaces/lib/dolly/preview'; +import { + createNewWorkspaceDollyConversation, + deleteWorkspaceDollyConversation, + getCachedWorkspaceDollyConversationState, + getWorkspaceDollyConversationState, + getWorkspaceDollyConversationsForTarget, + mergeWorkspaceDollyConversationState, + setSelectedWorkspaceDollyConversationId, + writeWorkspaceDollyConversationState, +} from 'src/modules/workspaces/lib/dolly/session'; +import { + getWorkspaceDollyErrorMessage, + isWorkspaceDollyRequestAbortError, + sendWorkspaceDollyMessage, +} from 'src/modules/workspaces/lib/dolly/transport'; +import { + abortWorkspaceDollyTurn, + finishWorkspaceDollyTurn, + getWorkspaceDollyTargetActivityKey, + getWorkspaceDollyTurn, + setWorkspaceDollyTargetUnread, + startWorkspaceDollyTurn, + useWorkspaceDollyConversationTurn, +} from 'src/modules/workspaces/lib/dolly/turns'; +import { + WORKSPACE_DOLLY_IMAGE_FILE_TYPES, + WORKSPACE_DOLLY_IMAGE_MAX_FILE_SIZE, + WORKSPACE_DOLLY_IMAGE_MAX_FILES, +} from 'src/modules/workspaces/lib/dolly/types'; +import { generateMessage, type Message as MessageType } from 'src/stores/chat-slice'; +import type { ToolProvider } from '@automattic/agenttic-client'; +import type { + AgentUIProps, + ImageUploaderHandle, + NoticeConfig as AgentticNoticeConfig, + UploadedImage, +} from '@automattic/agenttic-ui'; +import type { WorkspacePreviewState } from 'src/modules/workspaces/components/workspace-preview'; +import type { + WorkspaceDollyConversationState, + WorkspaceDollyMessageImageAttachment, + WorkspaceDollyPendingImage, + WorkspaceDollyTargetDescriptor, + WorkspaceDollyUploadedImage, +} from 'src/modules/workspaces/lib/dolly/types'; +import type { RemoteTarget, StudioWorkspace } from 'src/modules/workspaces/types'; + +type WorkspaceDollyAssistantProps = { + workspace: StudioWorkspace; + target: RemoteTarget; + previewState: WorkspacePreviewState; + onUpdatePreviewState: ( state: WorkspacePreviewState ) => void; +}; + +function OfflineModeView() { + return ( +
    + + + { __( 'The AI assistant requires an internet connection.' ) } + +
    + ); +} + +function UnauthenticatedView( { onAuthenticate }: { onAuthenticate: () => void } ) { + return ( + +
    + { __( 'Hold up!' ) } +
    +
    + { __( 'You need to log in to your WordPress.com account to use Dolly.' ) } +
    +
    + { sprintf( + __( 'Every account gets %d prompts included for free each month.' ), + LIMIT_OF_PROMPTS_PER_USER + ) } +
    + +
    + ); +} + +function WorkspaceDollyEmptyView() { + return diff --git a/apps/studio/src/modules/workspaces/components/workspace-target-switcher.tsx b/apps/studio/src/modules/workspaces/components/workspace-target-switcher.tsx deleted file mode 100644 index 627dd8afa8..0000000000 --- a/apps/studio/src/modules/workspaces/components/workspace-target-switcher.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { __, sprintf } from '@wordpress/i18n'; -import { Tooltip } from 'src/components/tooltip'; -import { cx } from 'src/lib/cx'; -import type { StudioWorkspace, WorkspaceTargetId } from 'src/modules/workspaces/types'; - -type WorkspaceTargetSwitcherProps = { - workspace: StudioWorkspace; - selectedTargetId?: WorkspaceTargetId; - onSelectTarget: ( targetId: WorkspaceTargetId ) => void; -}; - -const TARGET_ORDER: WorkspaceTargetId[] = [ 'production', 'staging', 'local' ]; - -function getTargetLabel( targetId: WorkspaceTargetId ) { - if ( targetId === 'production' ) { - return __( 'Production' ); - } - - if ( targetId === 'staging' ) { - return __( 'Staging' ); - } - - return __( 'Local' ); -} - -function getMissingTargetTooltip( targetId: WorkspaceTargetId ) { - return sprintf( - // translators: %s is a workspace target label, such as "Production", "Staging", or "Local". - __( '%s target is not available for this workspace.' ), - getTargetLabel( targetId ) - ); -} - -function getSelectTargetLabel( workspace: StudioWorkspace, targetId: WorkspaceTargetId ) { - const target = workspace.targets[ targetId ]; - const label = getTargetLabel( targetId ); - - if ( ! target ) { - return sprintf( - // translators: %s is a workspace target label, such as "Production", "Staging", or "Local". - __( '%s target unavailable' ), - label - ); - } - - if ( target.kind === 'local' ) { - return target.site.running - ? sprintf( - // translators: %s is the local site name. - __( 'Select Local target: %s is running' ), - target.site.name - ) - : sprintf( - // translators: %s is the local site name. - __( 'Select Local target: %s is stopped' ), - target.site.name - ); - } - - return sprintf( - // translators: 1: workspace target label, 2: remote site URL. - __( 'Select %1$s target: %2$s' ), - label, - target.site.url - ); -} - -function getDotClassName( targetId: WorkspaceTargetId ) { - if ( targetId === 'production' ) { - return 'bg-frame-theme'; - } - - if ( targetId === 'staging' ) { - return 'bg-a8c-blue-50'; - } - - return 'bg-a8c-gray-40'; -} - -export function WorkspaceTargetSwitcher( { - workspace, - selectedTargetId, - onSelectTarget, -}: WorkspaceTargetSwitcherProps ) { - return ( -
    - { TARGET_ORDER.map( ( targetId ) => { - const target = workspace.targets[ targetId ]; - const isSelected = selectedTargetId === targetId; - const isAvailable = Boolean( target ); - const label = getTargetLabel( targetId ); - const tooltip = isAvailable ? undefined : getMissingTargetTooltip( targetId ); - - return ( - - - - ); - } ) } -
    - ); -} diff --git a/apps/studio/src/modules/workspaces/hooks/use-workspace-selection.tsx b/apps/studio/src/modules/workspaces/hooks/use-workspace-selection.tsx index 97765af95e..0174bb2935 100644 --- a/apps/studio/src/modules/workspaces/hooks/use-workspace-selection.tsx +++ b/apps/studio/src/modules/workspaces/hooks/use-workspace-selection.tsx @@ -1,25 +1,13 @@ import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'; import { useSiteDetails } from 'src/hooks/use-site-details'; import { useSidebarWorkspaces } from 'src/modules/workspaces/hooks/use-sidebar-workspaces'; -import { useWorkspaceTargetSelection } from 'src/modules/workspaces/hooks/use-workspace-target-selection'; import { - getDefaultWorkspaceTargetId, - isWorkspaceTargetAvailable, -} from 'src/modules/workspaces/lib/target-selection'; -import { - getDefaultWorkspaceTargetTabId, - getWorkspaceTargetTabStorageKey, - isWorkspaceTargetTabId, + getDefaultWorkspaceTabId, + getWorkspaceTabStorageKey, + isWorkspaceTabId, } from 'src/modules/workspaces/lib/workspace-tabs'; import type { TabName } from 'src/hooks/use-content-tabs'; -import type { - LocalTarget, - RemoteTarget, - StudioWorkspace, - WorkspaceTargetId, -} from 'src/modules/workspaces/types'; - -type WorkspaceTarget = LocalTarget | RemoteTarget; +import type { StudioWorkspace } from 'src/modules/workspaces/types'; type WorkspaceSelectionContextValue = { enableWorkspaces: boolean; @@ -27,23 +15,18 @@ type WorkspaceSelectionContextValue = { isLoading: boolean; selectedWorkspace?: StudioWorkspace; selectedWorkspaceId?: string; - selectedTargetId?: WorkspaceTargetId; - selectedTarget?: WorkspaceTarget; - getSelectedTargetId: ( workspace: StudioWorkspace ) => WorkspaceTargetId | undefined; - selectWorkspaceTarget: ( workspaceId: string, targetId: WorkspaceTargetId ) => void; + selectWorkspace: ( workspaceId: string ) => void; selectedTabId?: TabName; - selectWorkspaceTab: ( workspaceId: string, targetId: WorkspaceTargetId, tabId: TabName ) => void; + selectWorkspaceTab: ( workspaceId: string, tabId: TabName ) => void; }; const WorkspaceSelectionContext = createContext< WorkspaceSelectionContextValue | undefined >( undefined ); -function readSavedTabId( workspaceId: string, targetId: WorkspaceTargetId ): TabName | undefined { +function readSavedTabId( workspace: StudioWorkspace ): TabName | undefined { try { - const savedTabId = localStorage.getItem( - getWorkspaceTargetTabStorageKey( workspaceId, targetId ) - ); + const savedTabId = localStorage.getItem( getWorkspaceTabStorageKey( workspace.id ) ); if ( savedTabId === 'overview' || savedTabId === 'sync' || @@ -52,7 +35,7 @@ function readSavedTabId( workspaceId: string, targetId: WorkspaceTargetId ): Tab savedTabId === 'import-export' || savedTabId === 'previews' ) { - return savedTabId; + return isWorkspaceTabId( workspace, savedTabId ) ? savedTabId : undefined; } } catch { return undefined; @@ -61,26 +44,18 @@ function readSavedTabId( workspaceId: string, targetId: WorkspaceTargetId ): Tab return undefined; } -function writeSavedTabId( workspaceId: string, targetId: WorkspaceTargetId, tabId: TabName ) { +function writeSavedTabId( workspaceId: string, tabId: TabName ) { try { - localStorage.setItem( getWorkspaceTargetTabStorageKey( workspaceId, targetId ), tabId ); + localStorage.setItem( getWorkspaceTabStorageKey( workspaceId ), tabId ); } catch { // Ignore storage failures; selection still works for the current render. } } -function getWorkspaceTargetKey( workspaceId: string, targetId: WorkspaceTargetId ) { - return `${ workspaceId }:${ targetId }`; -} - export function WorkspaceSelectionProvider( { children }: { children: ReactNode } ) { const { selectedSite, setSelectedSiteId } = useSiteDetails(); const { enableWorkspaces, sidebarWorkspaces: workspaces, isLoading } = useSidebarWorkspaces(); - const { - selectedWorkspaceId: explicitSelectedWorkspaceId, - getSelectedTargetId, - selectWorkspaceTarget: selectTarget, - } = useWorkspaceTargetSelection( workspaces ); + const [ explicitSelectedWorkspaceId, setExplicitSelectedWorkspaceId ] = useState< string >(); const [ selectedTabs, setSelectedTabs ] = useState< Record< string, TabName > >( {} ); const selectedSiteId = selectedSite?.id; @@ -104,85 +79,68 @@ export function WorkspaceSelectionProvider( { children }: { children: ReactNode return workspaces[ 0 ]; }, [ explicitSelectedWorkspaceId, selectedSiteId, workspaces ] ); - const selectedTargetId = selectedWorkspace ? getSelectedTargetId( selectedWorkspace ) : undefined; + const selectedTabId = useMemo( () => { + if ( ! selectedWorkspace ) { + return undefined; + } - const getSelectedTabId = useCallback( - ( workspaceId: string, targetId: WorkspaceTargetId ) => { - const selectedTabId = - selectedTabs[ getWorkspaceTargetKey( workspaceId, targetId ) ] ?? - readSavedTabId( workspaceId, targetId ); + const selectedTab = selectedTabs[ selectedWorkspace.id ] ?? readSavedTabId( selectedWorkspace ); - if ( selectedTabId && isWorkspaceTargetTabId( targetId, selectedTabId ) ) { - return selectedTabId; - } - - return getDefaultWorkspaceTargetTabId( targetId ); - }, - [ selectedTabs ] - ); + if ( selectedTab && isWorkspaceTabId( selectedWorkspace, selectedTab ) ) { + return selectedTab; + } - const selectedTabId = - selectedWorkspace && selectedTargetId - ? getSelectedTabId( selectedWorkspace.id, selectedTargetId ) - : undefined; + return getDefaultWorkspaceTabId( selectedWorkspace ); + }, [ selectedTabs, selectedWorkspace ] ); - const selectWorkspaceTarget = useCallback( - ( workspaceId: string, targetId: WorkspaceTargetId ) => { + const selectWorkspace = useCallback( + ( workspaceId: string ) => { const workspace = workspaces.find( ( candidate ) => candidate.id === workspaceId ); - if ( ! workspace || ! isWorkspaceTargetAvailable( workspace, targetId ) ) { + if ( ! workspace ) { return; } - selectTarget( workspaceId, targetId ); - if ( targetId === 'local' && workspace.targets.local ) { + setExplicitSelectedWorkspaceId( workspaceId ); + if ( workspace.targets.local ) { setSelectedSiteId( workspace.targets.local.siteId ); } }, - [ selectTarget, setSelectedSiteId, workspaces ] + [ setSelectedSiteId, workspaces ] ); const selectWorkspaceTab = useCallback( - ( workspaceId: string, targetId: WorkspaceTargetId, tabId: TabName ) => { - if ( ! isWorkspaceTargetTabId( targetId, tabId ) ) { + ( workspaceId: string, tabId: TabName ) => { + const workspace = workspaces.find( ( candidate ) => candidate.id === workspaceId ); + if ( ! workspace || ! isWorkspaceTabId( workspace, tabId ) ) { return; } setSelectedTabs( ( current ) => ( { ...current, - [ getWorkspaceTargetKey( workspaceId, targetId ) ]: tabId, + [ workspaceId ]: tabId, } ) ); - writeSavedTabId( workspaceId, targetId, tabId ); + writeSavedTabId( workspaceId, tabId ); }, - [] + [ workspaces ] ); const value = useMemo< WorkspaceSelectionContextValue >( () => { - const fallbackTargetId = - selectedWorkspace && ! selectedTargetId - ? getDefaultWorkspaceTargetId( selectedWorkspace ) - : selectedTargetId; - return { enableWorkspaces, workspaces, isLoading, selectedWorkspace, selectedWorkspaceId: selectedWorkspace?.id, - selectedTargetId: fallbackTargetId, - selectedTarget: fallbackTargetId ? selectedWorkspace?.targets[ fallbackTargetId ] : undefined, - getSelectedTargetId, - selectWorkspaceTarget, + selectWorkspace, selectedTabId, selectWorkspaceTab, }; }, [ enableWorkspaces, - getSelectedTargetId, isLoading, selectWorkspaceTab, - selectWorkspaceTarget, + selectWorkspace, selectedTabId, - selectedTargetId, selectedWorkspace, workspaces, ] ); diff --git a/apps/studio/src/modules/workspaces/hooks/use-workspace-target-selection.ts b/apps/studio/src/modules/workspaces/hooks/use-workspace-target-selection.ts deleted file mode 100644 index 1385ca8bdc..0000000000 --- a/apps/studio/src/modules/workspaces/hooks/use-workspace-target-selection.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { useCallback, useMemo, useState } from 'react'; -import { - getDefaultWorkspaceTargetId, - getWorkspaceTargetStorageKey, - isWorkspaceTargetAvailable, -} from 'src/modules/workspaces/lib/target-selection'; -import type { StudioWorkspace, WorkspaceTargetId } from 'src/modules/workspaces/types'; - -function readSavedTargetId( workspace: StudioWorkspace ): WorkspaceTargetId | undefined { - try { - const savedTargetId = localStorage.getItem( getWorkspaceTargetStorageKey( workspace.id ) ); - if ( - savedTargetId === 'local' || - savedTargetId === 'production' || - savedTargetId === 'staging' - ) { - return savedTargetId; - } - } catch { - return undefined; - } - - return undefined; -} - -function writeSavedTargetId( workspaceId: string, targetId: WorkspaceTargetId ) { - try { - localStorage.setItem( getWorkspaceTargetStorageKey( workspaceId ), targetId ); - } catch { - // Ignore storage failures; selection still works for the current render. - } -} - -export function useWorkspaceTargetSelection( workspaces: StudioWorkspace[] ) { - const [ selectedWorkspaceId, setSelectedWorkspaceId ] = useState< string | null >( null ); - const [ selectedTargets, setSelectedTargets ] = useState< Record< string, WorkspaceTargetId > >( - {} - ); - const workspacesById = useMemo( - () => new Map( workspaces.map( ( workspace ) => [ workspace.id, workspace ] ) ), - [ workspaces ] - ); - - const getSelectedTargetId = useCallback( - ( workspace: StudioWorkspace ) => { - const selectedTargetId = selectedTargets[ workspace.id ] ?? readSavedTargetId( workspace ); - if ( selectedTargetId && isWorkspaceTargetAvailable( workspace, selectedTargetId ) ) { - return selectedTargetId; - } - - return getDefaultWorkspaceTargetId( workspace ); - }, - [ selectedTargets ] - ); - - const selectWorkspaceTarget = useCallback( - ( workspaceId: string, targetId: WorkspaceTargetId ) => { - const workspace = workspacesById.get( workspaceId ); - if ( ! workspace || ! isWorkspaceTargetAvailable( workspace, targetId ) ) { - return; - } - - setSelectedWorkspaceId( workspaceId ); - setSelectedTargets( ( current ) => ( { - ...current, - [ workspaceId ]: targetId, - } ) ); - writeSavedTargetId( workspaceId, targetId ); - }, - [ workspacesById ] - ); - - return { - selectedWorkspaceId, - getSelectedTargetId, - selectWorkspaceTarget, - }; -} diff --git a/apps/studio/src/modules/workspaces/index.ts b/apps/studio/src/modules/workspaces/index.ts index 2bbd11f975..7a003c9bb9 100644 --- a/apps/studio/src/modules/workspaces/index.ts +++ b/apps/studio/src/modules/workspaces/index.ts @@ -3,22 +3,16 @@ export { createStudioWorkspaceId, mergeWpcomSitesWithConnectedSites, } from 'src/modules/workspaces/lib/build-studio-workspaces'; -export { - getDefaultWorkspaceTargetId, - getWorkspaceTargetStorageKey, - isWorkspaceTargetAvailable, -} from 'src/modules/workspaces/lib/target-selection'; export { useSidebarWorkspaces } from 'src/modules/workspaces/hooks/use-sidebar-workspaces'; export { useWorkspaceSelection, WorkspaceSelectionProvider, } from 'src/modules/workspaces/hooks/use-workspace-selection'; -export { useWorkspaceTargetSelection } from 'src/modules/workspaces/hooks/use-workspace-target-selection'; export { - getDefaultWorkspaceTargetTabId, - getWorkspaceTargetTabIds, - getWorkspaceTargetTabStorageKey, - isWorkspaceTargetTabId, + getDefaultWorkspaceTabId, + getWorkspaceTabIds, + getWorkspaceTabStorageKey, + isWorkspaceTabId, LOCAL_WORKSPACE_TAB_IDS, REMOTE_WORKSPACE_TAB_IDS, } from 'src/modules/workspaces/lib/workspace-tabs'; diff --git a/apps/studio/src/modules/workspaces/lib/dolly/api.ts b/apps/studio/src/modules/workspaces/lib/dolly/api.ts index 5bdf6fbc62..888d862f3f 100644 --- a/apps/studio/src/modules/workspaces/lib/dolly/api.ts +++ b/apps/studio/src/modules/workspaces/lib/dolly/api.ts @@ -9,7 +9,7 @@ import { type WorkspaceDollyHistoryChat, type WorkspaceDollyHistoryMessage, type WorkspaceDollyHistorySummary, - type WorkspaceDollyTargetDescriptor, + type WorkspaceDollyWorkspaceDescriptor, } from 'src/modules/workspaces/lib/dolly/types'; import { extractBackendSelectedSiteIdFromRecord, @@ -252,7 +252,7 @@ export const parseWorkspaceDollyHistoryChat = ( }; export const createWorkspaceDollyConversationStateFromHistoryItems = ( - target: WorkspaceDollyTargetDescriptor, + workspace: WorkspaceDollyWorkspaceDescriptor, historyItems: Array< { summary: WorkspaceDollyHistorySummary; chat?: WorkspaceDollyHistoryChat } > ): WorkspaceDollyConversationState | undefined => { const sortedHistoryItems = [ ...historyItems ].sort( @@ -282,9 +282,7 @@ export const createWorkspaceDollyConversationStateFromHistoryItems = ( return { id: `wpcom:${ WORKSPACE_DOLLY_AGENT_ID }:${ latestHistoryItem.summary.chatId }`, key: { - workspaceId: target.workspaceId, - targetId: target.targetId, - siteId: target.site.id, + workspaceId: workspace.workspaceId, agentId: WORKSPACE_DOLLY_AGENT_ID, }, remoteChatId: latestHistoryItem.summary.chatId, @@ -397,21 +395,26 @@ export const fetchWorkspaceDollyHistoryChat = async ( export const hydrateWorkspaceDollyConversationStates = async ( client: WPCOM, - target: WorkspaceDollyTargetDescriptor, + workspace: WorkspaceDollyWorkspaceDescriptor, preferredSessionId?: string ): Promise< WorkspaceDollyConversationState[] > => { const summaries = await fetchWorkspaceDollyHistorySummaries( client ); const normalizedPreferredSessionId = normalizeDollySessionId( preferredSessionId ); const groupedSummaries = new Map< string, WorkspaceDollyHistorySummary[] >(); + const remoteSiteIds = new Set( workspace.remoteTargets.map( ( target ) => target.site.id ) ); for ( const summary of summaries ) { const normalizedSessionId = normalizeDollySessionId( summary.sessionId ); const matchesPreferredSession = normalizedPreferredSessionId && normalizedSessionId === normalizedPreferredSessionId && - ( summary.siteId === target.site.id || summary.siteId === undefined ); + ( summary.siteId === undefined || remoteSiteIds.has( summary.siteId ) ); - if ( summary.siteId !== target.site.id && ! matchesPreferredSession ) { + if ( + summary.siteId !== undefined && + ! remoteSiteIds.has( summary.siteId ) && + ! matchesPreferredSession + ) { continue; } @@ -445,7 +448,7 @@ export const hydrateWorkspaceDollyConversationStates = async ( } const conversationState = createWorkspaceDollyConversationStateFromHistoryItems( - target, + workspace, historyItems ); diff --git a/apps/studio/src/modules/workspaces/lib/dolly/preview.ts b/apps/studio/src/modules/workspaces/lib/dolly/preview.ts index b35c063bee..96f1016424 100644 --- a/apps/studio/src/modules/workspaces/lib/dolly/preview.ts +++ b/apps/studio/src/modules/workspaces/lib/dolly/preview.ts @@ -12,18 +12,33 @@ import { } from 'src/modules/workspaces/lib/dolly/types'; import { hasHttpProtocol } from 'src/modules/workspaces/lib/dolly/utils'; import type { Ability, ContextProvider } from '@automattic/agenttic-client'; -import type { SyncSite } from '@studio/common/types/sync'; import type { WorkspacePreviewState } from 'src/modules/workspaces/components/workspace-preview'; -import type { RemoteTargetId } from 'src/modules/workspaces/types'; +import type { + RemoteTarget, + StudioWorkspace, + WorkspaceTargetId, +} from 'src/modules/workspaces/types'; type OpenPreviewOptions = { forceReload?: boolean; }; type PreviewAbilityContext = { - site: SyncSite; + targets: PreviewAbilityTarget[]; previewState: WorkspacePreviewState; - openPreview: ( pathOrUrl?: string, options?: OpenPreviewOptions ) => void; + openPreview: ( + targetId: WorkspaceTargetId, + pathOrUrl?: string, + options?: OpenPreviewOptions + ) => void; +}; + +export type PreviewAbilityTarget = { + targetId: WorkspaceTargetId; + siteId?: number | string; + siteName: string; + siteUrl: string; + isProduction?: boolean; }; const getStringValue = ( @@ -59,6 +74,13 @@ const getBooleanValue = ( } }; +const getTargetIdValue = ( record: Record< string, unknown > ): WorkspaceTargetId | undefined => { + const targetId = getStringValue( record, [ 'targetId', 'target_id', 'target' ] ); + if ( targetId === 'local' || targetId === 'staging' || targetId === 'production' ) { + return targetId; + } +}; + const shouldForcePreviewReload = ( toolArguments: Record< string, unknown > ): boolean => getBooleanValue( toolArguments, [ 'siteChanged', @@ -92,15 +114,21 @@ const createWorkspaceDollyPreviewAbility = ( name: WORKSPACE_DOLLY_PREVIEW_TOOL_ID, label: 'Preview URL', description: - 'Open a web URL in the WordPress Studio side preview panel for the currently selected workspace target.', + 'Open a web URL in the WordPress Studio side preview panel for an explicit workspace target.', category: 'interface', input_schema: { type: 'object', properties: { + targetId: { + type: 'string', + enum: [ 'local', 'staging', 'production' ], + description: + 'Required workspace target to preview. Use local when available for safest read-only inspection, staging before production for remote previews.', + }, url: { type: 'string', description: - 'The absolute http or https URL to preview. Studio also accepts paths relative to the selected WordPress.com site, such as / or /wp-admin/.', + 'The absolute http or https URL to preview. Studio also accepts paths relative to the requested workspace target, such as / or /wp-admin/.', }, siteChanged: { type: 'boolean', @@ -108,7 +136,7 @@ const createWorkspaceDollyPreviewAbility = ( 'Set true only after changing the selected WordPress.com site so Studio refreshes the current preview.', }, }, - required: [ 'url' ], + required: [ 'targetId', 'url' ], }, output_schema: { type: 'object', @@ -136,21 +164,28 @@ const createWorkspaceDollyRefreshPreviewAbility = ( name: WORKSPACE_DOLLY_REFRESH_PREVIEW_TOOL_ID, label: 'Refresh Preview', description: - 'Refresh the currently open WordPress Studio side preview panel after the selected WordPress.com site has changed.', + 'Refresh the currently open WordPress Studio side preview panel after an explicit workspace target has changed.', category: 'interface', input_schema: { type: 'object', properties: { + targetId: { + type: 'string', + enum: [ 'local', 'staging', 'production' ], + description: + 'Required workspace target to refresh. Production should only be used after explicit user confirmation for production-impacting changes.', + }, url: { type: 'string', description: - 'Optional absolute http or https URL, or path relative to the selected WordPress.com site.', + 'Optional absolute http or https URL, or path relative to the requested workspace target.', }, reason: { type: 'string', description: 'Short reason the preview needs to refresh.', }, }, + required: [ 'targetId' ], }, output_schema: { type: 'object', @@ -174,21 +209,30 @@ const createWorkspaceDollyRefreshPreviewAbility = ( } ); export const createWorkspaceDollyPreviewAbilities = ( { - site, + targets, previewState, openPreview, }: PreviewAbilityContext ): Ability[] => [ createWorkspaceDollyPreviewAbility( ( input: Record< string, unknown > ) => { + const targetId = getTargetIdValue( input ); + const target = targets.find( ( candidate ) => candidate.targetId === targetId ); + if ( ! target ) { + return { + success: false, + error: 'Preview needs a valid targetId: local, staging, or production.', + }; + } + const requestedUrl = getStringValue( input, [ 'url', 'URL', 'uri', 'path' ] ); if ( ! requestedUrl ) { return { success: false, - error: 'Preview needs a valid URL or WordPress.com site path.', + error: 'Preview needs a valid URL or workspace target path.', }; } - const normalizedUrl = normalizeWorkspaceDollyPreviewUrl( site.url, requestedUrl ); - openPreview( requestedUrl, { + const normalizedUrl = normalizeWorkspaceDollyPreviewUrl( target.siteUrl, requestedUrl ); + openPreview( target.targetId, requestedUrl, { forceReload: shouldForcePreviewReload( input ), } ); @@ -199,9 +243,19 @@ export const createWorkspaceDollyPreviewAbilities = ( { }; } ), createWorkspaceDollyRefreshPreviewAbility( ( input: Record< string, unknown > ) => { + const targetId = getTargetIdValue( input ); + const target = targets.find( ( candidate ) => candidate.targetId === targetId ); + if ( ! target ) { + return { + success: false, + refreshed: false, + error: 'Refresh preview needs a valid targetId: local, staging, or production.', + }; + } + const requestedUrl = getStringValue( input, [ 'url', 'URL', 'uri', 'path' ] ); const refreshUrl = requestedUrl || previewState.currentUrl || previewState.pathOrUrl || '/'; - const normalizedUrl = normalizeWorkspaceDollyPreviewUrl( site.url, refreshUrl ); + const normalizedUrl = normalizeWorkspaceDollyPreviewUrl( target.siteUrl, refreshUrl ); if ( ! previewState.open ) { return { @@ -212,7 +266,7 @@ export const createWorkspaceDollyPreviewAbilities = ( { }; } - openPreview( refreshUrl, { forceReload: true } ); + openPreview( target.targetId, refreshUrl, { forceReload: true } ); return { success: true, @@ -224,14 +278,16 @@ export const createWorkspaceDollyPreviewAbilities = ( { ]; export const createWorkspaceDollyPreviewContext = ( - siteId: number, + targetId: WorkspaceTargetId | undefined, siteUrl: string, - previewState: WorkspacePreviewState + previewState: WorkspacePreviewState, + siteId?: number | string ): WorkspaceDollyPreviewContext => { const openedURL = resolveWorkspacePreviewUrl( siteUrl, previewState.pathOrUrl ); return { isOpen: previewState.open, + targetId, siteId, openedURL, currentURL: previewState.currentUrl ?? openedURL, @@ -241,77 +297,128 @@ export const createWorkspaceDollyPreviewContext = ( export const createWorkspaceDollySiteAssociationContext = ( { workspaceId, - targetId, - site, + workspace, + transportTarget, + activeTarget, + activeUrl, + targets, }: { workspaceId: string; - targetId: RemoteTargetId; - site: SyncSite; + workspace: StudioWorkspace; + transportTarget: RemoteTarget; + activeTarget?: PreviewAbilityTarget; + activeUrl?: string; + targets: PreviewAbilityTarget[]; } ): WorkspaceDollySiteAssociationContext => ( { - status: 'workspace_target', + status: 'workspace', workspaceId, - targetId, - wpcomSiteId: site.id, - wpcomSiteUrl: site.url, - instructions: - 'This WordPress.com site is the selected target in WordPress Studio. Keep all actions scoped to this selected workspace target unless the user explicitly switches targets.', + transportTargetId: transportTarget.id, + transportWpcomSiteId: transportTarget.site.id, + transportWpcomSiteUrl: transportTarget.site.url, + activeTargetId: activeTarget?.targetId, + activeSiteId: activeTarget?.siteId, + activeSiteUrl: activeUrl ?? activeTarget?.siteUrl, + activeSiteBaseUrl: activeTarget?.siteUrl, + targets: targets.map( ( target ) => ( { + targetId: target.targetId, + siteId: target.siteId, + siteUrl: target.siteUrl, + isProduction: target.targetId === 'production', + } ) ), + instructions: `This WordPress Studio workspace is "${ workspace.name }". Local, staging, and production are capabilities inside the workspace, not separate chats. Every target-specific action must choose an explicit targetId. Ask when a requested change is ambiguous. Prefer local, then staging, then production for safe read-only defaults. Production-impacting actions require clear user confirmation before acting.`, } ); export const createWorkspaceDollyClientContext = ( workspaceId: string, - targetId: RemoteTargetId, - site: SyncSite, + workspace: StudioWorkspace, + transportTarget: RemoteTarget, previewContext: WorkspaceDollyPreviewContext, - siteAssociation: WorkspaceDollySiteAssociationContext -) => ( { - constructorArguments: { - client: WORKSPACE_DOLLY_HISTORY_CLIENT, - }, - selectedSiteId: site.id, - preview: previewContext, - studioSiteAssociation: siteAssociation, - frontendAbilities: WORKSPACE_DOLLY_FRONTEND_ABILITIES, - wpworkspace: { - appName: window.appGlobals?.appName ?? 'WordPress Studio', - currentActivity: 'Working on a WordPress.com workspace target selected from Studio', - clientVersion: window.appGlobals?.appVersion, - workspace: { - id: workspaceId, - targetId, - }, - selectedSite: { - id: site.id, - name: site.name, - url: site.url, - siteId: site.id, - kind: 'wpcom-site', + siteAssociation: WorkspaceDollySiteAssociationContext, + targets: PreviewAbilityTarget[] +) => { + const activeTarget = targets.find( ( target ) => target.targetId === previewContext.targetId ); + const activeTargetUrl = siteAssociation.activeSiteUrl ?? activeTarget?.siteUrl; + + return { + constructorArguments: { + client: WORKSPACE_DOLLY_HISTORY_CLIENT, }, + selectedSiteId: transportTarget.site.id, preview: previewContext, studioSiteAssociation: siteAssociation, frontendAbilities: WORKSPACE_DOLLY_FRONTEND_ABILITIES, - previewRefreshPolicy: { - afterVisibleSiteChange: - 'When a successful action changes the selected site and preview.isOpen is true, call wpworkspace/refresh_preview before the final reply.', - hiddenPreviewBehavior: - 'Do not open a hidden preview just to auto-refresh. Use wpworkspace/preview only when the user asks to open or show a preview.', + wpworkspace: { + appName: window.appGlobals?.appName ?? 'WordPress Studio', + currentActivity: + 'Working in one WordPress Studio workspace. Targets are explicit tool contexts, not separate chats.', + clientVersion: window.appGlobals?.appVersion, + workspace: { + id: workspaceId, + name: workspace.name, + activeTarget: activeTarget + ? { + targetId: activeTarget.targetId, + siteId: activeTarget.siteId, + name: activeTarget.siteName, + url: activeTargetUrl, + siteUrl: activeTarget.siteUrl, + currentUrl: previewContext.currentURL ?? activeTargetUrl, + isProduction: activeTarget.targetId === 'production', + } + : undefined, + targets: targets.map( ( target ) => ( { + targetId: target.targetId, + siteId: target.siteId, + name: target.siteName, + url: target.siteUrl, + isProduction: target.targetId === 'production', + } ) ), + }, + selectedSite: { + id: transportTarget.site.id, + name: transportTarget.site.name, + url: transportTarget.site.url, + siteId: transportTarget.site.id, + kind: 'wpcom-site', + transportTargetId: transportTarget.id, + }, + preview: previewContext, + studioSiteAssociation: siteAssociation, + frontendAbilities: WORKSPACE_DOLLY_FRONTEND_ABILITIES, + targetPolicy: { + required: + 'Every tool call that reads, previews, syncs, or mutates a site must include an explicit targetId.', + ambiguousChanges: + 'Ask the user which target to change unless the safest target is clearly local or staging.', + production: + 'Production-impacting actions require clear confirmation and should be called out visibly.', + }, + previewRefreshPolicy: { + afterVisibleSiteChange: + 'When a successful action changes a visible target and preview.isOpen is true, call wpworkspace/refresh_preview with that explicit targetId before the final reply.', + hiddenPreviewBehavior: + 'Do not open a hidden preview just to auto-refresh. Use wpworkspace/preview only when the user asks to open or show a preview.', + }, }, - }, -} ); + }; +}; export const createWorkspaceDollyContextProvider = ( workspaceId: string, - targetId: RemoteTargetId, - site: SyncSite, + workspace: StudioWorkspace, + transportTarget: RemoteTarget, previewContext: WorkspaceDollyPreviewContext, - siteAssociation: WorkspaceDollySiteAssociationContext + siteAssociation: WorkspaceDollySiteAssociationContext, + targets: PreviewAbilityTarget[] ): ContextProvider => ( { getClientContext: () => createWorkspaceDollyClientContext( workspaceId, - targetId, - site, + workspace, + transportTarget, previewContext, - siteAssociation + siteAssociation, + targets ), } ); @@ -324,11 +431,8 @@ export const createWorkspaceDollyAuthProvider = export const createWorkspaceDollyAgentUrl = ( siteId: number ) => `${ WORKSPACE_DOLLY_AGENT_URL_ORIGIN }/sites/${ siteId }/ai/agent`; -export const createWorkspaceDollyAgentManagerKey = ( - workspaceId: string, - targetId: RemoteTargetId, - siteId: number -) => `${ workspaceId }:${ targetId }:${ siteId }:${ WORKSPACE_DOLLY_HISTORY_CLIENT }`; +export const createWorkspaceDollyAgentManagerKey = ( workspaceId: string, siteId: number ) => + `${ workspaceId }:${ siteId }:${ WORKSPACE_DOLLY_HISTORY_CLIENT }`; const isHttpUrl = ( value: string ) => { try { diff --git a/apps/studio/src/modules/workspaces/lib/dolly/session.ts b/apps/studio/src/modules/workspaces/lib/dolly/session.ts index 0c13d00a1d..77a86d55aa 100644 --- a/apps/studio/src/modules/workspaces/lib/dolly/session.ts +++ b/apps/studio/src/modules/workspaces/lib/dolly/session.ts @@ -1,40 +1,70 @@ -import { clearWorkspaceDollyTargetActivityForTests } from 'src/modules/workspaces/lib/dolly/turns'; +import { useMemo, useSyncExternalStore } from 'react'; +import { clearWorkspaceDollyWorkspaceActivityForTests } from 'src/modules/workspaces/lib/dolly/turns'; import { WORKSPACE_DOLLY_AGENT_ID, type WorkspaceDollyConversationState, - type WorkspaceDollyTargetDescriptor, + type WorkspaceDollyWorkspaceDescriptor, } from 'src/modules/workspaces/lib/dolly/types'; import { flexibleNumber, isRecord, normalizeDollySessionId, } from 'src/modules/workspaces/lib/dolly/utils'; +import type { RemoteTarget, StudioWorkspace } from 'src/modules/workspaces/types'; import type { Message as MessageType } from 'src/stores/chat-slice'; -export const WORKSPACE_DOLLY_CONVERSATIONS_STORAGE_KEY = 'studio_workspace_dolly_conversations_v1'; +export const WORKSPACE_DOLLY_CONVERSATIONS_STORAGE_KEY = 'studio_workspace_dolly_conversations_v2'; type PersistedWorkspaceDollyCache = { - version: 1; + version: 2; conversations: Record< string, WorkspaceDollyConversationState >; - selectedConversationIdsByTargetKey: Record< string, string >; - hiddenRemoteConversationKeysByTargetKey: Record< string, string[] >; + selectedConversationIdsByWorkspaceId: Record< string, string >; + hiddenRemoteConversationKeysByWorkspaceId: Record< string, string[] >; }; const conversationCache = new Map< string, WorkspaceDollyConversationState >(); -const selectedConversationIdsByTargetKey = new Map< string, string >(); -const hiddenRemoteConversationKeysByTargetKey = new Map< string, Set< string > >(); +const selectedConversationIdsByWorkspaceId = new Map< string, string >(); +const hiddenRemoteConversationKeysByWorkspaceId = new Map< string, Set< string > >(); +const conversationCacheSubscribers = new Set< () => void >(); let hasLoadedConversationCache = false; +let conversationCacheVersion = 0; -export const createWorkspaceDollyTargetCacheKey = ( { +const emitConversationCacheChange = () => { + conversationCacheVersion += 1; + for ( const subscriber of conversationCacheSubscribers ) { + subscriber(); + } +}; + +const subscribeWorkspaceDollyConversationCache = ( subscriber: () => void ) => { + conversationCacheSubscribers.add( subscriber ); + return () => { + conversationCacheSubscribers.delete( subscriber ); + }; +}; + +const getWorkspaceDollyConversationCacheSnapshot = () => { + loadWorkspaceDollyConversationCache(); + return conversationCacheVersion; +}; + +export const createWorkspaceDollyWorkspaceCacheKey = ( { workspaceId, - targetId, - site, -}: WorkspaceDollyTargetDescriptor ) => `${ workspaceId }:${ targetId }:${ site.id }`; +}: WorkspaceDollyWorkspaceDescriptor ) => workspaceId; + +export const createWorkspaceDollyWorkspaceDescriptor = ( + workspace: StudioWorkspace +): WorkspaceDollyWorkspaceDescriptor => ( { + workspaceId: workspace.id, + workspace, + remoteTargets: [ workspace.targets.production, workspace.targets.staging ].filter( + ( target ): target is RemoteTarget => Boolean( target ) + ), +} ); -const createWorkspaceDollyConversationTargetCacheKey = ( +const createWorkspaceDollyConversationWorkspaceCacheKey = ( conversationState: WorkspaceDollyConversationState -) => - `${ conversationState.key.workspaceId }:${ conversationState.key.targetId }:${ conversationState.key.siteId }`; +) => conversationState.key.workspaceId; export const createWorkspaceDollyConversationId = () => `local:${ crypto.randomUUID() }`; @@ -55,8 +85,8 @@ const createRemoteConversationKeys = ( }; const isRemoteConversationHidden = ( conversationState: WorkspaceDollyConversationState ) => { - const hiddenKeys = hiddenRemoteConversationKeysByTargetKey.get( - createWorkspaceDollyConversationTargetCacheKey( conversationState ) + const hiddenKeys = hiddenRemoteConversationKeysByWorkspaceId.get( + createWorkspaceDollyConversationWorkspaceCacheKey( conversationState ) ); if ( ! hiddenKeys ) { return false; @@ -71,11 +101,11 @@ const addHiddenRemoteConversation = ( conversationState: WorkspaceDollyConversat return; } - const targetKey = createWorkspaceDollyConversationTargetCacheKey( conversationState ); + const workspaceId = createWorkspaceDollyConversationWorkspaceCacheKey( conversationState ); const hiddenKeys = - hiddenRemoteConversationKeysByTargetKey.get( targetKey ) ?? new Set< string >(); + hiddenRemoteConversationKeysByWorkspaceId.get( workspaceId ) ?? new Set< string >(); keys.forEach( ( key ) => hiddenKeys.add( key ) ); - hiddenRemoteConversationKeysByTargetKey.set( targetKey, hiddenKeys ); + hiddenRemoteConversationKeysByWorkspaceId.set( workspaceId, hiddenKeys ); }; export const cloneWorkspaceDollyConversationState = ( @@ -97,14 +127,9 @@ const normalizePersistedWorkspaceDollyConversationState = ( } const workspaceId = typeof value.key.workspaceId === 'string' ? value.key.workspaceId : undefined; - const targetId = - value.key.targetId === 'production' || value.key.targetId === 'staging' - ? value.key.targetId - : undefined; - const siteId = flexibleNumber( value.key.siteId ); const agentId = typeof value.key.agentId === 'string' ? value.key.agentId : undefined; - if ( ! workspaceId || ! targetId || ! siteId || agentId !== WORKSPACE_DOLLY_AGENT_ID ) { + if ( ! workspaceId || agentId !== WORKSPACE_DOLLY_AGENT_ID ) { return undefined; } @@ -117,8 +142,6 @@ const normalizePersistedWorkspaceDollyConversationState = ( id, key: { workspaceId, - targetId, - siteId, agentId: WORKSPACE_DOLLY_AGENT_ID, }, remoteChatId: flexibleNumber( value.remoteChatId ), @@ -139,14 +162,14 @@ const normalizePersistedWorkspaceDollyConversationState = ( const addConversationStateToCache = ( conversationState: WorkspaceDollyConversationState ) => { conversationCache.set( conversationState.id, conversationState ); - const targetKey = createWorkspaceDollyConversationTargetCacheKey( conversationState ); - if ( ! selectedConversationIdsByTargetKey.has( targetKey ) ) { - selectedConversationIdsByTargetKey.set( targetKey, conversationState.id ); + const workspaceId = createWorkspaceDollyConversationWorkspaceCacheKey( conversationState ); + if ( ! selectedConversationIdsByWorkspaceId.has( workspaceId ) ) { + selectedConversationIdsByWorkspaceId.set( workspaceId, conversationState.id ); } }; const loadPersistedWorkspaceDollyCache = ( parsed: unknown ) => { - if ( ! isRecord( parsed ) || parsed.version !== 1 || ! isRecord( parsed.conversations ) ) { + if ( ! isRecord( parsed ) || parsed.version !== 2 || ! isRecord( parsed.conversations ) ) { return false; } @@ -157,23 +180,23 @@ const loadPersistedWorkspaceDollyCache = ( parsed: unknown ) => { } } - if ( isRecord( parsed.selectedConversationIdsByTargetKey ) ) { - for ( const [ targetKey, conversationId ] of Object.entries( - parsed.selectedConversationIdsByTargetKey + if ( isRecord( parsed.selectedConversationIdsByWorkspaceId ) ) { + for ( const [ workspaceId, conversationId ] of Object.entries( + parsed.selectedConversationIdsByWorkspaceId ) ) { if ( typeof conversationId === 'string' && conversationCache.has( conversationId ) ) { - selectedConversationIdsByTargetKey.set( targetKey, conversationId ); + selectedConversationIdsByWorkspaceId.set( workspaceId, conversationId ); } } } - if ( isRecord( parsed.hiddenRemoteConversationKeysByTargetKey ) ) { - for ( const [ targetKey, hiddenKeys ] of Object.entries( - parsed.hiddenRemoteConversationKeysByTargetKey + if ( isRecord( parsed.hiddenRemoteConversationKeysByWorkspaceId ) ) { + for ( const [ workspaceId, hiddenKeys ] of Object.entries( + parsed.hiddenRemoteConversationKeysByWorkspaceId ) ) { if ( Array.isArray( hiddenKeys ) ) { - hiddenRemoteConversationKeysByTargetKey.set( - targetKey, + hiddenRemoteConversationKeysByWorkspaceId.set( + workspaceId, new Set( hiddenKeys.filter( ( hiddenKey ): hiddenKey is string => typeof hiddenKey === 'string' ) ) @@ -205,35 +228,32 @@ export const loadWorkspaceDollyConversationCache = () => { export const persistWorkspaceDollyConversationCache = () => { const cache: PersistedWorkspaceDollyCache = { - version: 1, + version: 2, conversations: Object.fromEntries( Array.from( conversationCache.entries() ).map( ( [ key, value ] ) => [ key, cloneWorkspaceDollyConversationState( value ), ] ) ), - selectedConversationIdsByTargetKey: Object.fromEntries( - selectedConversationIdsByTargetKey.entries() + selectedConversationIdsByWorkspaceId: Object.fromEntries( + selectedConversationIdsByWorkspaceId.entries() ), - hiddenRemoteConversationKeysByTargetKey: Object.fromEntries( - Array.from( hiddenRemoteConversationKeysByTargetKey.entries() ).map( - ( [ targetKey, hiddenKeys ] ) => [ targetKey, Array.from( hiddenKeys ) ] + hiddenRemoteConversationKeysByWorkspaceId: Object.fromEntries( + Array.from( hiddenRemoteConversationKeysByWorkspaceId.entries() ).map( + ( [ workspaceId, hiddenKeys ] ) => [ workspaceId, Array.from( hiddenKeys ) ] ) ), }; localStorage.setItem( WORKSPACE_DOLLY_CONVERSATIONS_STORAGE_KEY, JSON.stringify( cache ) ); + emitConversationCacheChange(); }; export const createWorkspaceDollyConversationState = ( { workspaceId, - targetId, - site, -}: WorkspaceDollyTargetDescriptor ): WorkspaceDollyConversationState => ( { +}: WorkspaceDollyWorkspaceDescriptor ): WorkspaceDollyConversationState => ( { id: createWorkspaceDollyConversationId(), key: { workspaceId, - targetId, - siteId: site.id, agentId: WORKSPACE_DOLLY_AGENT_ID, }, remoteChatId: undefined, @@ -245,56 +265,66 @@ export const createWorkspaceDollyConversationState = ( { } ); export const setSelectedWorkspaceDollyConversationId = ( - target: WorkspaceDollyTargetDescriptor, + workspace: WorkspaceDollyWorkspaceDescriptor, conversationId: string ) => { - selectedConversationIdsByTargetKey.set( - createWorkspaceDollyTargetCacheKey( target ), + selectedConversationIdsByWorkspaceId.set( + createWorkspaceDollyWorkspaceCacheKey( workspace ), conversationId ); persistWorkspaceDollyConversationCache(); }; -export const createNewWorkspaceDollyConversation = ( target: WorkspaceDollyTargetDescriptor ) => { +export const createNewWorkspaceDollyConversation = ( + workspace: WorkspaceDollyWorkspaceDescriptor +) => { loadWorkspaceDollyConversationCache(); - const conversationState = createWorkspaceDollyConversationState( target ); + const conversationState = createWorkspaceDollyConversationState( workspace ); conversationCache.set( conversationState.id, conversationState ); - setSelectedWorkspaceDollyConversationId( target, conversationState.id ); + setSelectedWorkspaceDollyConversationId( workspace, conversationState.id ); return cloneWorkspaceDollyConversationState( conversationState ); }; -const conversationMatchesTarget = ( +const conversationMatchesWorkspace = ( conversationState: WorkspaceDollyConversationState, - { workspaceId, targetId, site }: WorkspaceDollyTargetDescriptor -) => - conversationState.key.workspaceId === workspaceId && - conversationState.key.targetId === targetId && - conversationState.key.siteId === site.id; - -export const getWorkspaceDollyConversationsForTarget = ( - target: WorkspaceDollyTargetDescriptor + { workspaceId }: WorkspaceDollyWorkspaceDescriptor +) => conversationState.key.workspaceId === workspaceId; + +export const getWorkspaceDollyConversationsForWorkspace = ( + workspace: WorkspaceDollyWorkspaceDescriptor ) => { loadWorkspaceDollyConversationCache(); return Array.from( conversationCache.values() ) - .filter( ( conversationState ) => conversationMatchesTarget( conversationState, target ) ) + .filter( ( conversationState ) => conversationMatchesWorkspace( conversationState, workspace ) ) .filter( ( conversationState ) => ! isRemoteConversationHidden( conversationState ) ) .sort( ( first, second ) => second.lastUpdated - first.lastUpdated ) .map( cloneWorkspaceDollyConversationState ); }; -export const getWorkspaceDollyConversationState = ( target: WorkspaceDollyTargetDescriptor ) => { +export const getSelectedWorkspaceDollyConversationId = ( + workspace: WorkspaceDollyWorkspaceDescriptor +) => { + loadWorkspaceDollyConversationCache(); + return selectedConversationIdsByWorkspaceId.get( + createWorkspaceDollyWorkspaceCacheKey( workspace ) + ); +}; + +export const getWorkspaceDollyConversationState = ( + workspace: WorkspaceDollyWorkspaceDescriptor +) => { loadWorkspaceDollyConversationCache(); - const targetKey = createWorkspaceDollyTargetCacheKey( target ); - const selectedConversationId = selectedConversationIdsByTargetKey.get( targetKey ); + const workspaceId = createWorkspaceDollyWorkspaceCacheKey( workspace ); + const selectedConversationId = selectedConversationIdsByWorkspaceId.get( workspaceId ); const cachedConversationState = selectedConversationId ? conversationCache.get( selectedConversationId ) : undefined; if ( ! cachedConversationState || - ! conversationMatchesTarget( cachedConversationState, target ) + ! conversationMatchesWorkspace( cachedConversationState, workspace ) ) { - return createNewWorkspaceDollyConversation( target ); + return createNewWorkspaceDollyConversation( workspace ); } return cloneWorkspaceDollyConversationState( cachedConversationState ); @@ -311,8 +341,8 @@ export const writeWorkspaceDollyConversationState = ( ) => { loadWorkspaceDollyConversationCache(); conversationCache.set( conversationState.id, conversationState ); - selectedConversationIdsByTargetKey.set( - createWorkspaceDollyConversationTargetCacheKey( conversationState ), + selectedConversationIdsByWorkspaceId.set( + createWorkspaceDollyConversationWorkspaceCacheKey( conversationState ), conversationState.id ); persistWorkspaceDollyConversationCache(); @@ -321,23 +351,23 @@ export const writeWorkspaceDollyConversationState = ( export const deleteWorkspaceDollyConversation = ( conversationId: string, - target: WorkspaceDollyTargetDescriptor + workspace: WorkspaceDollyWorkspaceDescriptor ) => { loadWorkspaceDollyConversationCache(); const conversationState = conversationCache.get( conversationId ); - if ( conversationState && conversationMatchesTarget( conversationState, target ) ) { + if ( conversationState && conversationMatchesWorkspace( conversationState, workspace ) ) { addHiddenRemoteConversation( conversationState ); conversationCache.delete( conversationId ); } - const conversations = getWorkspaceDollyConversationsForTarget( target ); + const conversations = getWorkspaceDollyConversationsForWorkspace( workspace ); const nextConversation = conversations[ 0 ]; if ( nextConversation ) { - setSelectedWorkspaceDollyConversationId( target, nextConversation.id ); + setSelectedWorkspaceDollyConversationId( workspace, nextConversation.id ); return nextConversation; } - return createNewWorkspaceDollyConversation( target ); + return createNewWorkspaceDollyConversation( workspace ); }; export const shouldApplyWorkspaceDollyHydration = ( @@ -391,11 +421,7 @@ export const mergeWorkspaceDollyConversationState = ( } const matchingConversation = Array.from( conversationCache.values() ).find( ( candidate ) => { - if ( - candidate.key.workspaceId !== hydratedConversationState.key.workspaceId || - candidate.key.targetId !== hydratedConversationState.key.targetId || - candidate.key.siteId !== hydratedConversationState.key.siteId - ) { + if ( candidate.key.workspaceId !== hydratedConversationState.key.workspaceId ) { return false; } @@ -434,8 +460,8 @@ export const mergeWorkspaceDollyConversationState = ( conversationCache.set( nextConversationState.id, nextConversationState ); - const targetKey = createWorkspaceDollyConversationTargetCacheKey( nextConversationState ); - const selectedConversationId = selectedConversationIdsByTargetKey.get( targetKey ); + const workspaceId = createWorkspaceDollyConversationWorkspaceCacheKey( nextConversationState ); + const selectedConversationId = selectedConversationIdsByWorkspaceId.get( workspaceId ); const selectedConversation = selectedConversationId ? conversationCache.get( selectedConversationId ) : undefined; @@ -445,7 +471,7 @@ export const mergeWorkspaceDollyConversationState = ( selectedConversation.messages.length === 0 && ! selectedConversation.input.trim() ) ) { - selectedConversationIdsByTargetKey.set( targetKey, nextConversationState.id ); + selectedConversationIdsByWorkspaceId.set( workspaceId, nextConversationState.id ); } persistWorkspaceDollyConversationCache(); @@ -454,9 +480,40 @@ export const mergeWorkspaceDollyConversationState = ( export const clearWorkspaceDollyAssistantStateCacheForTests = () => { conversationCache.clear(); - selectedConversationIdsByTargetKey.clear(); - hiddenRemoteConversationKeysByTargetKey.clear(); - clearWorkspaceDollyTargetActivityForTests(); + selectedConversationIdsByWorkspaceId.clear(); + hiddenRemoteConversationKeysByWorkspaceId.clear(); + clearWorkspaceDollyWorkspaceActivityForTests(); hasLoadedConversationCache = false; localStorage.removeItem( WORKSPACE_DOLLY_CONVERSATIONS_STORAGE_KEY ); + emitConversationCacheChange(); +}; + +export const useWorkspaceDollyConversationsForWorkspace = ( + workspace: WorkspaceDollyWorkspaceDescriptor +) => { + const version = useSyncExternalStore( + subscribeWorkspaceDollyConversationCache, + getWorkspaceDollyConversationCacheSnapshot, + () => 0 + ); + + return useMemo( () => { + void version; + return getWorkspaceDollyConversationsForWorkspace( workspace ); + }, [ version, workspace ] ); +}; + +export const useSelectedWorkspaceDollyConversationId = ( + workspace: WorkspaceDollyWorkspaceDescriptor +) => { + const version = useSyncExternalStore( + subscribeWorkspaceDollyConversationCache, + getWorkspaceDollyConversationCacheSnapshot, + () => 0 + ); + + return useMemo( () => { + void version; + return getSelectedWorkspaceDollyConversationId( workspace ); + }, [ version, workspace ] ); }; diff --git a/apps/studio/src/modules/workspaces/lib/dolly/transport.ts b/apps/studio/src/modules/workspaces/lib/dolly/transport.ts index a558b44228..fc3cf1bb22 100644 --- a/apps/studio/src/modules/workspaces/lib/dolly/transport.ts +++ b/apps/studio/src/modules/workspaces/lib/dolly/transport.ts @@ -22,8 +22,8 @@ import { type WorkspaceDollyUploadedImage, } from 'src/modules/workspaces/lib/dolly/types'; import { extractBackendSelectedSiteId } from 'src/modules/workspaces/lib/dolly/utils'; -import type { SyncSite } from '@studio/common/types/sync'; -import type { RemoteTargetId } from 'src/modules/workspaces/types'; +import type { PreviewAbilityTarget } from 'src/modules/workspaces/lib/dolly/preview'; +import type { RemoteTarget, StudioWorkspace } from 'src/modules/workspaces/types'; export const getWorkspaceDollyErrorMessage = ( error: unknown ) => error instanceof Error ? error.message : String( error ); @@ -103,10 +103,11 @@ export const sendWorkspaceDollyMessage = async ( { uploadedImages, previewContext, siteAssociation, - selectedSite, + workspace, + transportTarget, sessionId, workspaceId, - targetId, + targets, toolProvider, abortSignal, }: { @@ -114,10 +115,11 @@ export const sendWorkspaceDollyMessage = async ( { uploadedImages?: WorkspaceDollyUploadedImage[]; previewContext: WorkspaceDollyPreviewContext; siteAssociation: WorkspaceDollySiteAssociationContext; - selectedSite: SyncSite; + workspace: StudioWorkspace; + transportTarget: RemoteTarget; sessionId?: string; workspaceId: string; - targetId: RemoteTargetId; + targets: PreviewAbilityTarget[]; toolProvider?: ToolProvider; abortSignal?: AbortSignal; } ): Promise< WorkspaceDollyAgentResponse > => { @@ -126,21 +128,21 @@ export const sendWorkspaceDollyMessage = async ( { const agentManager = getAgentManager(); const agentManagerKey = createWorkspaceDollyAgentManagerKey( workspaceId, - targetId, - selectedSite.id + transportTarget.site.id ); const sendInitialMessage = async ( nextTaskId: string, nextSessionId: string ) => { agentManager.removeAgent( agentManagerKey ); await agentManager.createAgent( agentManagerKey, { agentId: WORKSPACE_DOLLY_AGENT_ID, - agentUrl: createWorkspaceDollyAgentUrl( selectedSite.id ), + agentUrl: createWorkspaceDollyAgentUrl( transportTarget.site.id ), authProvider: createWorkspaceDollyAuthProvider(), contextProvider: createWorkspaceDollyContextProvider( workspaceId, - targetId, - selectedSite, + workspace, + transportTarget, previewContext, - siteAssociation + siteAssociation, + targets ), toolProvider, timeout: WORKSPACE_DOLLY_REQUEST_TIMEOUT_MS, diff --git a/apps/studio/src/modules/workspaces/lib/dolly/turns.ts b/apps/studio/src/modules/workspaces/lib/dolly/turns.ts index a809906c88..04d6bdfc22 100644 --- a/apps/studio/src/modules/workspaces/lib/dolly/turns.ts +++ b/apps/studio/src/modules/workspaces/lib/dolly/turns.ts @@ -1,17 +1,14 @@ import { useMemo, useSyncExternalStore } from 'react'; -import type { WorkspaceDollyTargetActivity } from 'src/modules/workspaces/lib/dolly/types'; -import type { RemoteTargetId } from 'src/modules/workspaces/types'; +import type { WorkspaceDollyWorkspaceActivity } from 'src/modules/workspaces/lib/dolly/types'; type WorkspaceDollyTurn = { conversationId: string; workspaceId: string; - targetId: RemoteTargetId; - siteId: number; abortController: AbortController; }; const activeTurns = new Map< string, WorkspaceDollyTurn >(); -const targetActivities = new Map< string, WorkspaceDollyTargetActivity >(); +const workspaceActivities = new Map< string, WorkspaceDollyWorkspaceActivity >(); const subscribers = new Set< () => void >(); let activityVersion = 0; @@ -29,44 +26,29 @@ const subscribe = ( subscriber: () => void ) => { }; }; -export const getWorkspaceDollyTargetActivityKey = ( { - workspaceId, - targetId, - siteId, -}: { - workspaceId: string; - targetId: RemoteTargetId; - siteId: number; -} ) => `${ workspaceId }:${ targetId }:${ siteId }`; - -const setTargetActivity = ( - targetKey: string, - activity: Partial< WorkspaceDollyTargetActivity > +const setWorkspaceActivity = ( + workspaceId: string, + activity: Partial< WorkspaceDollyWorkspaceActivity > ) => { const nextActivity = { - ...targetActivities.get( targetKey ), + ...workspaceActivities.get( workspaceId ), ...activity, }; const hasActiveActivity = Object.values( nextActivity ).some( Boolean ); if ( hasActiveActivity ) { - targetActivities.set( targetKey, nextActivity ); + workspaceActivities.set( workspaceId, nextActivity ); } else { - targetActivities.delete( targetKey ); + workspaceActivities.delete( workspaceId ); } emitChange(); }; -const refreshThinkingActivity = ( targetKey: string ) => { +const refreshThinkingActivity = ( workspaceId: string ) => { const hasActiveTurn = Array.from( activeTurns.values() ).some( - ( turn ) => - getWorkspaceDollyTargetActivityKey( { - workspaceId: turn.workspaceId, - targetId: turn.targetId, - siteId: turn.siteId, - } ) === targetKey + ( turn ) => turn.workspaceId === workspaceId ); - setTargetActivity( targetKey, { isAssistantThinking: hasActiveTurn } ); + setWorkspaceActivity( workspaceId, { isAssistantThinking: hasActiveTurn } ); }; export const getWorkspaceDollyTurn = ( conversationId: string ) => @@ -74,14 +56,7 @@ export const getWorkspaceDollyTurn = ( conversationId: string ) => export const startWorkspaceDollyTurn = ( turn: WorkspaceDollyTurn ) => { activeTurns.set( turn.conversationId, turn ); - setTargetActivity( - getWorkspaceDollyTargetActivityKey( { - workspaceId: turn.workspaceId, - targetId: turn.targetId, - siteId: turn.siteId, - } ), - { isAssistantThinking: true } - ); + setWorkspaceActivity( turn.workspaceId, { isAssistantThinking: true } ); }; export const finishWorkspaceDollyTurn = ( @@ -94,31 +69,23 @@ export const finishWorkspaceDollyTurn = ( } activeTurns.delete( conversationId ); - refreshThinkingActivity( - getWorkspaceDollyTargetActivityKey( { - workspaceId: activeTurn.workspaceId, - targetId: activeTurn.targetId, - siteId: activeTurn.siteId, - } ) - ); + refreshThinkingActivity( activeTurn.workspaceId ); }; export const abortWorkspaceDollyTurn = ( conversationId: string ) => { activeTurns.get( conversationId )?.abortController.abort(); }; -export const setWorkspaceDollyTargetUnread = ( - target: { workspaceId: string; targetId: RemoteTargetId; siteId: number }, +export const setWorkspaceDollyWorkspaceUnread = ( + workspaceId: string, hasUnreadAssistantMessage: boolean ) => { - setTargetActivity( getWorkspaceDollyTargetActivityKey( target ), { - hasUnreadAssistantMessage, - } ); + setWorkspaceActivity( workspaceId, { hasUnreadAssistantMessage } ); }; -export const clearWorkspaceDollyTargetActivityForTests = () => { +export const clearWorkspaceDollyWorkspaceActivityForTests = () => { activeTurns.clear(); - targetActivities.clear(); + workspaceActivities.clear(); emitChange(); }; @@ -129,7 +96,7 @@ export const useWorkspaceDollyConversationTurn = ( conversationId: string ) => () => false ); -export const useWorkspaceDollyTargetActivities = ( workspaceId: string ) => { +export const useWorkspaceDollyWorkspaceActivity = ( workspaceId: string ) => { const version = useSyncExternalStore( subscribe, () => activityVersion, @@ -138,12 +105,6 @@ export const useWorkspaceDollyTargetActivities = ( workspaceId: string ) => { return useMemo( () => { void version; - const activities: Record< string, WorkspaceDollyTargetActivity > = {}; - for ( const [ key, activity ] of targetActivities.entries() ) { - if ( key.startsWith( `${ workspaceId }:` ) ) { - activities[ key ] = { ...activity }; - } - } - return activities; + return { ...workspaceActivities.get( workspaceId ) }; }, [ version, workspaceId ] ); }; diff --git a/apps/studio/src/modules/workspaces/lib/dolly/types.ts b/apps/studio/src/modules/workspaces/lib/dolly/types.ts index 61078fa375..858b11e824 100644 --- a/apps/studio/src/modules/workspaces/lib/dolly/types.ts +++ b/apps/studio/src/modules/workspaces/lib/dolly/types.ts @@ -1,7 +1,10 @@ import type { SendMessageParams } from '@automattic/agenttic-client'; import type { UploadedImage } from '@automattic/agenttic-ui'; -import type { SyncSite } from '@studio/common/types/sync'; -import type { RemoteTargetId } from 'src/modules/workspaces/types'; +import type { + RemoteTarget, + WorkspaceTargetId, + StudioWorkspace, +} from 'src/modules/workspaces/types'; import type { Message as MessageType } from 'src/stores/chat-slice'; export const WORKSPACE_DOLLY_AGENT_ID = 'dolly'; @@ -32,8 +35,6 @@ export const WORKSPACE_DOLLY_IMAGE_PRELOAD_TIMEOUT_MS = 750; export type WorkspaceDollyConversationKey = { workspaceId: string; - targetId: RemoteTargetId; - siteId: number; agentId: typeof WORKSPACE_DOLLY_AGENT_ID; }; @@ -48,7 +49,7 @@ export type WorkspaceDollyConversationState = { lastUpdated: number; }; -export type WorkspaceDollyTargetActivity = { +export type WorkspaceDollyWorkspaceActivity = { isAssistantThinking?: boolean; hasUnreadAssistantMessage?: boolean; }; @@ -111,23 +112,34 @@ export type WorkspaceDollyHistoryChat = { export type WorkspaceDollyPreviewContext = { isOpen: boolean; - siteId: number; + targetId?: WorkspaceTargetId; + siteId?: number | string; openedURL?: string; currentURL?: string; isLoading: boolean; }; export type WorkspaceDollySiteAssociationContext = { - status: 'workspace_target'; + status: 'workspace'; workspaceId: string; - targetId: RemoteTargetId; - wpcomSiteId: number; - wpcomSiteUrl: string; + transportTargetId: RemoteTarget[ 'id' ]; + transportWpcomSiteId: number; + transportWpcomSiteUrl: string; + activeTargetId?: WorkspaceTargetId; + activeSiteId?: number | string; + activeSiteUrl?: string; + activeSiteBaseUrl?: string; + targets: Array< { + targetId: WorkspaceTargetId; + siteId?: number | string; + siteUrl: string; + isProduction?: boolean; + } >; instructions: string; }; -export type WorkspaceDollyTargetDescriptor = { +export type WorkspaceDollyWorkspaceDescriptor = { workspaceId: string; - targetId: RemoteTargetId; - site: SyncSite; + workspace?: StudioWorkspace; + remoteTargets: RemoteTarget[]; }; diff --git a/apps/studio/src/modules/workspaces/lib/target-selection.ts b/apps/studio/src/modules/workspaces/lib/target-selection.ts deleted file mode 100644 index 0dbd06ea44..0000000000 --- a/apps/studio/src/modules/workspaces/lib/target-selection.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { StudioWorkspace, WorkspaceTargetId } from 'src/modules/workspaces/types'; - -const WORKSPACE_TARGET_STORAGE_PREFIX = 'studio-workspace-target:'; - -export function getDefaultWorkspaceTargetId( - workspace: StudioWorkspace -): WorkspaceTargetId | undefined { - if ( workspace.targets.local ) { - return 'local'; - } - - if ( workspace.targets.production ) { - return 'production'; - } - - if ( workspace.targets.staging ) { - return 'staging'; - } - - return undefined; -} - -export function isWorkspaceTargetAvailable( - workspace: StudioWorkspace, - targetId: WorkspaceTargetId -) { - return Boolean( workspace.targets[ targetId ] ); -} - -export function getWorkspaceTargetStorageKey( workspaceId: string ) { - return `${ WORKSPACE_TARGET_STORAGE_PREFIX }${ workspaceId }`; -} diff --git a/apps/studio/src/modules/workspaces/lib/workspace-tabs.ts b/apps/studio/src/modules/workspaces/lib/workspace-tabs.ts index dcd0e71cc6..448699b0d6 100644 --- a/apps/studio/src/modules/workspaces/lib/workspace-tabs.ts +++ b/apps/studio/src/modules/workspaces/lib/workspace-tabs.ts @@ -1,34 +1,31 @@ import type { TabName } from 'src/hooks/use-content-tabs'; -import type { WorkspaceTargetId } from 'src/modules/workspaces/types'; +import type { StudioWorkspace } from 'src/modules/workspaces/types'; -const WORKSPACE_TARGET_TAB_STORAGE_PREFIX = 'studio-workspace-target-tab:'; +const WORKSPACE_TAB_STORAGE_PREFIX = 'studio-workspace-tab:'; export const LOCAL_WORKSPACE_TAB_IDS: TabName[] = [ 'overview', + 'assistant', 'sync', 'previews', 'import-export', 'settings', - 'assistant', ]; export const REMOTE_WORKSPACE_TAB_IDS: TabName[] = [ 'assistant', 'sync', 'settings' ]; -export function getWorkspaceTargetTabIds( targetId: WorkspaceTargetId ): TabName[] { - return targetId === 'local' ? LOCAL_WORKSPACE_TAB_IDS : REMOTE_WORKSPACE_TAB_IDS; +export function getWorkspaceTabIds( workspace: StudioWorkspace ): TabName[] { + return workspace.targets.local ? LOCAL_WORKSPACE_TAB_IDS : REMOTE_WORKSPACE_TAB_IDS; } -export function isWorkspaceTargetTabId( targetId: WorkspaceTargetId, tabId: TabName ) { - return getWorkspaceTargetTabIds( targetId ).includes( tabId ); +export function isWorkspaceTabId( workspace: StudioWorkspace, tabId: TabName ) { + return getWorkspaceTabIds( workspace ).includes( tabId ); } -export function getDefaultWorkspaceTargetTabId( targetId: WorkspaceTargetId ): TabName { - return targetId === 'local' ? 'overview' : 'assistant'; +export function getDefaultWorkspaceTabId( workspace: StudioWorkspace ): TabName { + return workspace.targets.local ? 'overview' : 'assistant'; } -export function getWorkspaceTargetTabStorageKey( - workspaceId: string, - targetId: WorkspaceTargetId -) { - return `${ WORKSPACE_TARGET_TAB_STORAGE_PREFIX }${ workspaceId }:${ targetId }`; +export function getWorkspaceTabStorageKey( workspaceId: string ) { + return `${ WORKSPACE_TAB_STORAGE_PREFIX }${ workspaceId }`; } diff --git a/apps/studio/src/site-server.ts b/apps/studio/src/site-server.ts index 34b0b4907d..88fbfa3255 100644 --- a/apps/studio/src/site-server.ts +++ b/apps/studio/src/site-server.ts @@ -228,6 +228,13 @@ export class SiteServer { console.log( `Starting server for '${ this.details.name }'` ); await this.server.start(); + const url = getAbsoluteUrl( this.details ); + this.details = { + ...this.details, + running: true, + url, + }; + this.server.url = url; } updateSiteDetails( site: SiteDetails ) { diff --git a/apps/studio/src/tests/site-server.test.ts b/apps/studio/src/tests/site-server.test.ts index fa1c963549..88b30545f5 100644 --- a/apps/studio/src/tests/site-server.test.ts +++ b/apps/studio/src/tests/site-server.test.ts @@ -140,6 +140,36 @@ describe( 'SiteServer', () => { } ); describe( 'start', () => { + beforeEach( () => { + mockStartServer.mockReset(); + mockStartServer.mockResolvedValue( { + url: 'http://localhost:1234', + options: { port: 1234, phpVersion: '8.0' }, + _internal: { mode: 'wordpress', port: 1234 }, + } ); + } ); + + it( 'transitions stopped details to running after the CLI server starts', async () => { + const server = SiteServer.register( { + id: 'start-id', + name: 'start-name', + path: 'start-path', + port: 9191, + adminPassword: 'test-password', + phpVersion: '8.4', + running: false, + themeDetails: undefined, + } ); + + await server.start(); + + expect( server.details.running ).toBe( true ); + if ( server.details.running ) { + expect( server.details.url ).toBe( 'http://localhost:9191' ); + } + expect( server.server.url ).toBe( 'http://localhost:9191' ); + } ); + it( 'should throw if the server starts with a non-WordPress mode', async () => { mockStartServer.mockRejectedValue( new Error( From fe72df14aae41f6a29ccdf577b56576424c727db Mon Sep 17 00:00:00 2001 From: dereksmart Date: Mon, 18 May 2026 15:59:29 -0400 Subject: [PATCH 6/7] Add workspace sync setup controls What changed. Added a workspace Sync panel behind enableWorkspaces that renders Local <-> Production, Local <-> Staging, and Production <-> Staging rows from the selected StudioWorkspace targets instead of falling back to a generic empty state when links are missing. Reused Studio's existing remote-site creation/pull flow through a new createSiteFromRemoteSite() helper so remote-only workspaces can create a local copy without duplicating the add-site workflow. Added staging sync state and thunks for Production/Staging push, pull, sync-state polling, and staging-site creation through the WordPress.com staging API. Extracted shared sync modal pieces and file-selection controls so workspace environment sync uses the same confirmation shape as the existing local sync dialog, including granular file selection where the API supports path sync. Added setup-state handling for sites that cannot support local sync or staging creation: upgrade plan, enable hosting, missing permissions, unsupported, deleted, and already-connected states now show specific affordances instead of a misleading create/connect action. What stayed unchanged / intentionally deferred. The enableWorkspaces-off path and existing local sync behavior remain unchanged. This does not replace the existing SyncConnectedSiteControls for local/remote sync; workspace rows delegate to them when a target is already connected. This does not solve backend Production/Staging content-copy gaps observed in WordPress.com; Studio now calls the same environment sync endpoints and exposes supported file/database options. Staging creation is started and the workspace list is refreshed, but detailed provisioning/progress UI is deferred. Dolly/tool-driven sync actions and production mutation confirmation flows remain separate future work. Tradeoffs and risks. Local setup eligibility currently follows the existing SyncSite.syncSupport model. If WordPress.com exposes a more precise 'supports local copy' capability later, this UI should move to that explicit field. Staging setup eligibility is derived from preserved WP.com metadata such as hasStagingSiteFeature, canManageOptions, and isWpcomAtomic. Missing metadata is treated optimistically to avoid hiding setup on older responses. Granular Production/Staging file sync depends on Rewind file-tree path IDs being available for the source environment. Verification. npx eslint --fix apps/studio/src/hooks/use-add-site.ts apps/studio/src/hooks/tests/use-add-site.test.tsx apps/studio/src/modules/workspaces/hooks/use-workspace-selection.tsx apps/studio/src/modules/workspaces/hooks/use-sidebar-workspaces.ts apps/studio/src/modules/sync/components/workspace-sync-panel.tsx apps/studio/src/stores/sync/staging-sync-slice.ts apps/studio/src/stores/sync/staging-sync-slice.test.ts apps/studio/src/components/tests/site-content-tabs.test.tsx npm test -- apps/studio/src/components/tests/site-content-tabs.test.tsx apps/studio/src/stores/sync/staging-sync-slice.test.ts apps/studio/src/hooks/tests/use-add-site.test.tsx apps/studio/src/modules/sync/tests/index.test.tsx apps/studio/src/modules/workspaces/lib/build-studio-workspaces.test.ts apps/studio/src/components/tests/main-sidebar.test.tsx npm run typecheck git diff --check --- .../tests/site-content-tabs.test.tsx | 320 ++++++ .../src/hooks/tests/use-add-site.test.tsx | 60 ++ apps/studio/src/hooks/use-add-site.ts | 54 ++ .../sync/components/sync-connected-sites.tsx | 28 +- .../modules/sync/components/sync-dialog.tsx | 256 ++--- .../components/sync-files-select-control.tsx | 45 + .../sync/components/sync-modal-shell.tsx | 88 ++ .../sync/components/workspace-sync-panel.tsx | 918 ++++++++++++++++++ .../src/modules/sync/tests/index.test.tsx | 6 +- .../components/workspace-content-shell.tsx | 39 +- .../hooks/use-sidebar-workspaces.ts | 14 +- .../hooks/use-workspace-selection.tsx | 10 +- apps/studio/src/stores/index.ts | 4 +- apps/studio/src/stores/sync/index.ts | 15 + .../stores/sync/staging-sync-slice.test.ts | 335 +++++++ .../src/stores/sync/staging-sync-slice.ts | 452 +++++++++ 16 files changed, 2444 insertions(+), 200 deletions(-) create mode 100644 apps/studio/src/modules/sync/components/sync-files-select-control.tsx create mode 100644 apps/studio/src/modules/sync/components/sync-modal-shell.tsx create mode 100644 apps/studio/src/modules/sync/components/workspace-sync-panel.tsx create mode 100644 apps/studio/src/stores/sync/staging-sync-slice.test.ts create mode 100644 apps/studio/src/stores/sync/staging-sync-slice.ts diff --git a/apps/studio/src/components/tests/site-content-tabs.test.tsx b/apps/studio/src/components/tests/site-content-tabs.test.tsx index 5f3d149732..6f18fae43c 100644 --- a/apps/studio/src/components/tests/site-content-tabs.test.tsx +++ b/apps/studio/src/components/tests/site-content-tabs.test.tsx @@ -7,6 +7,7 @@ import { ContentTabsProvider } from 'src/hooks/use-content-tabs'; import { useSiteDetails } from 'src/hooks/use-site-details'; import { WorkspaceSelectionProvider } from 'src/modules/workspaces'; import { store } from 'src/stores'; +import { syncOperationsActions } from 'src/stores/sync'; import { testActions, testReducer } from 'src/stores/tests/utils/test-reducer'; import type { SyncSite } from '@studio/common/types/sync'; import type { WorkspacePreviewState } from 'src/modules/workspaces/components/workspace-preview'; @@ -18,6 +19,10 @@ const featureFlagsMock = vi.hoisted( () => ( { enableWorkspaces: false, } ) ); const useGetWpComSitesQueryMock = vi.hoisted( () => vi.fn() ); +const syncHooksMock = vi.hoisted( () => ( { + useLatestRewindId: vi.fn(), + useRemoteFileTree: vi.fn(), +} ) ); const selectedSite: SiteDetails = { id: 'site-id-1', @@ -87,6 +92,11 @@ vi.mock( 'src/lib/get-ipc-api', async () => ( { updateConnectedWpcomSites: vi.fn(), getUserTerminal: vi.fn().mockResolvedValue( 'terminal' ), getUserEditor: vi.fn().mockResolvedValue( 'vscode' ), + showErrorMessageBox: vi.fn(), + showMessageBox: vi.fn().mockResolvedValue( { response: 0 } ), + showNotification: vi.fn(), + connectWpcomSites: vi.fn().mockResolvedValue( undefined ), + openURL: vi.fn(), setWindowControlVisibility: vi.fn(), } ), } ) ); @@ -126,6 +136,16 @@ vi.mock( 'src/stores/sync/wpcom-sites', async () => { useGetWpComSitesQuery: useGetWpComSitesQueryMock, }; } ); +vi.mock( 'src/stores/sync/sync-hooks', async () => { + const actual = await vi.importActual< typeof import('src/stores/sync/sync-hooks') >( + 'src/stores/sync/sync-hooks' + ); + return { + ...actual, + useLatestRewindId: syncHooksMock.useLatestRewindId, + useRemoteFileTree: syncHooksMock.useRemoteFileTree, + }; +} ); store.replaceReducer( testReducer ); @@ -168,6 +188,7 @@ const mockWpcomSitesQuery = ( sites: SyncSite[] = [] ) => { data: { sites, total: sites.length, page: 1, perPage: 100 }, isLoading: false, isFetching: false, + refetch: vi.fn(), } ); }; @@ -177,6 +198,16 @@ describe( 'SiteContentTabs', () => { localStorage.clear(); featureFlagsMock.enableWorkspaces = false; mockWpcomSitesQuery(); + syncHooksMock.useLatestRewindId.mockReturnValue( { + rewindId: null, + isLoading: false, + isError: false, + } ); + syncHooksMock.useRemoteFileTree.mockReturnValue( { + fetchChildren: vi.fn().mockResolvedValue( [] ), + isLoading: false, + error: null, + } ); store.dispatch( testActions.resetState() ); } ); const renderWithProvider = ( component: React.ReactElement ) => { @@ -648,4 +679,293 @@ describe( 'SiteContentTabs', () => { screen.queryByLabelText( 'Local target: Test Site is stopped' ) ).not.toBeInTheDocument(); } ); + + it( 'renders workspace sync controls for local, production, and staging links', async () => { + const user = userEvent.setup(); + featureFlagsMock.enableWorkspaces = true; + const productionSite = createSyncSite( { + id: 101, + localSiteId: selectedSite.id, + syncSupport: 'already-connected', + name: 'Linked Workspace', + url: 'https://production.example', + stagingSiteIds: [ 202 ], + } ); + const stagingSite = createSyncSite( { + id: 202, + localSiteId: selectedSite.id, + syncSupport: 'already-connected', + name: 'Linked Workspace Staging', + url: 'https://staging.example', + isStaging: true, + productionSiteId: 101, + } ); + mockWpcomSitesQuery( [ productionSite, stagingSite ] ); + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn( { + selectedSite, + sites: [ selectedSite ], + } ), + } ); + + await act( async () => renderWithProvider( ) ); + await user.click( screen.getByRole( 'tab', { name: 'Sync' } ) ); + + expect( screen.getByTestId( 'workspace-sync-panel' ) ).toBeVisible(); + expect( screen.getByText( 'Local <-> Production' ) ).toBeVisible(); + expect( screen.getByText( 'Local <-> Staging' ) ).toBeVisible(); + expect( screen.getByText( 'Production <-> Staging' ) ).toBeVisible(); + expect( screen.getByRole( 'button', { name: 'Push to Staging' } ) ).toBeEnabled(); + expect( screen.getByRole( 'button', { name: 'Pull to Production' } ) ).toBeEnabled(); + + await user.click( screen.getByRole( 'button', { name: 'Pull to Production' } ) ); + + expect( screen.getByRole( 'heading', { name: 'Pull from Staging' } ) ).toBeVisible(); + expect( screen.getByRole( 'checkbox', { name: 'Files and folders' } ) ).not.toBeChecked(); + expect( screen.getByText( 'All files and folders' ) ).toBeVisible(); + expect( screen.getByRole( 'checkbox', { name: 'Database' } ) ).not.toBeChecked(); + expect( screen.queryByText( 'Root files' ) ).not.toBeInTheDocument(); + expect( screen.getByTestId( 'environment-sync-submit-button' ) ).toBeDisabled(); + + await user.click( screen.getByRole( 'checkbox', { name: 'Files and folders' } ) ); + + expect( screen.getByTestId( 'environment-sync-submit-button' ) ).toBeEnabled(); + } ); + + it( 'offers setup actions for a remote-only production workspace', async () => { + const user = userEvent.setup(); + featureFlagsMock.enableWorkspaces = true; + const productionSite = createSyncSite( { + id: 101, + name: 'Remote Workspace', + url: 'https://remote-workspace.example', + hasStagingSiteFeature: true, + canManageOptions: true, + } ); + mockWpcomSitesQuery( [ productionSite ] ); + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn( { selectedSite: null, sites: [] } ), + } ); + + await act( async () => renderWithProvider( ) ); + await user.click( screen.getByRole( 'tab', { name: 'Sync' } ) ); + + expect( + screen.queryByText( 'No workspace sync links are available yet.' ) + ).not.toBeInTheDocument(); + expect( screen.getByText( 'Local <-> Production' ) ).toBeVisible(); + expect( screen.getByRole( 'button', { name: 'Create local copy' } ) ).toBeVisible(); + expect( screen.getByText( 'Production <-> Staging' ) ).toBeVisible(); + expect( screen.getByRole( 'button', { name: 'Create staging site' } ) ).toBeVisible(); + } ); + + it( 'does not offer local copy setup when a remote site cannot support Studio sync', async () => { + const user = userEvent.setup(); + featureFlagsMock.enableWorkspaces = true; + const productionSite = createSyncSite( { + id: 101, + name: 'Remote Workspace', + url: 'https://remote-workspace.example', + syncSupport: 'needs-upgrade', + hasStagingSiteFeature: true, + canManageOptions: true, + isWpcomAtomic: true, + } ); + mockWpcomSitesQuery( [ productionSite ] ); + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn( { selectedSite: null, sites: [] } ), + } ); + + await act( async () => renderWithProvider( ) ); + await user.click( screen.getByRole( 'tab', { name: 'Sync' } ) ); + + expect( + screen.getByText( 'Upgrade this site plan before creating or connecting a local version.' ) + ).toBeVisible(); + expect( screen.queryByRole( 'button', { name: 'Create local copy' } ) ).not.toBeInTheDocument(); + expect( screen.getByRole( 'button', { name: /Upgrade plan/ } ) ).toBeVisible(); + } ); + + it( 'does not offer staging creation when production is not eligible for staging sites', async () => { + const user = userEvent.setup(); + featureFlagsMock.enableWorkspaces = true; + const productionSite = createSyncSite( { + id: 101, + name: 'Remote Workspace', + url: 'https://remote-workspace.example', + syncSupport: 'syncable', + hasStagingSiteFeature: false, + canManageOptions: true, + isWpcomAtomic: true, + } ); + mockWpcomSitesQuery( [ productionSite ] ); + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn( { selectedSite: null, sites: [] } ), + } ); + + await act( async () => renderWithProvider( ) ); + await user.click( screen.getByRole( 'tab', { name: 'Sync' } ) ); + + expect( screen.getByText( 'This site plan does not include staging sites.' ) ).toBeVisible(); + expect( + screen.queryByRole( 'button', { name: 'Create staging site' } ) + ).not.toBeInTheDocument(); + expect( screen.getByRole( 'button', { name: /Upgrade plan/ } ) ).toBeVisible(); + } ); + + it( 'offers to connect an unlinked production target when local and staging are connected', async () => { + const user = userEvent.setup(); + featureFlagsMock.enableWorkspaces = true; + const productionSite = createSyncSite( { + id: 101, + localSiteId: '', + syncSupport: 'syncable', + name: 'Linked Workspace', + url: 'https://production.example', + stagingSiteIds: [ 202 ], + } ); + const stagingSite = createSyncSite( { + id: 202, + localSiteId: selectedSite.id, + syncSupport: 'already-connected', + name: 'Linked Workspace Staging', + url: 'https://staging.example', + isStaging: true, + productionSiteId: 101, + } ); + mockWpcomSitesQuery( [ productionSite, stagingSite ] ); + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn( { + selectedSite, + sites: [ selectedSite ], + } ), + } ); + + await act( async () => renderWithProvider( ) ); + await user.click( screen.getByRole( 'tab', { name: 'Sync' } ) ); + + expect( screen.getByText( 'Local <-> Production' ) ).toBeVisible(); + expect( + screen.getByText( 'Connect this target to the local site before syncing.' ) + ).toBeVisible(); + expect( screen.getByRole( 'button', { name: 'Connect' } ) ).toBeEnabled(); + expect( screen.getByText( 'Local <-> Staging' ) ).toBeVisible(); + } ); + + it( 'lets Production/Staging sync select specific source files', async () => { + const user = userEvent.setup(); + const fetchChildren = vi.fn().mockResolvedValue( [ + { + id: 'plugin-path', + name: 'akismet', + label: 'akismet', + checked: false, + type: 'plugin', + pathId: 'cjI6,ZjI6YWtpc21ldC8=', + path: '/wp-content/plugins/akismet/', + }, + ] ); + syncHooksMock.useLatestRewindId.mockReturnValue( { + rewindId: '1234567890', + isLoading: false, + isError: false, + } ); + syncHooksMock.useRemoteFileTree.mockReturnValue( { + fetchChildren, + isLoading: false, + error: null, + } ); + featureFlagsMock.enableWorkspaces = true; + const productionSite = createSyncSite( { + id: 101, + localSiteId: selectedSite.id, + syncSupport: 'already-connected', + name: 'Linked Workspace', + url: 'https://production.example', + stagingSiteIds: [ 202 ], + } ); + const stagingSite = createSyncSite( { + id: 202, + localSiteId: selectedSite.id, + syncSupport: 'already-connected', + name: 'Linked Workspace Staging', + url: 'https://staging.example', + isStaging: true, + productionSiteId: 101, + } ); + mockWpcomSitesQuery( [ productionSite, stagingSite ] ); + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn( { + selectedSite, + sites: [ selectedSite ], + } ), + } ); + + await act( async () => renderWithProvider( ) ); + await user.click( screen.getByRole( 'tab', { name: 'Sync' } ) ); + await user.click( screen.getByRole( 'button', { name: 'Pull to Production' } ) ); + await user.selectOptions( + screen.getByRole( 'combobox', { name: 'Select files and folders to sync' } ), + 'specific' + ); + + const sourceFileCheckbox = await screen.findByRole( 'checkbox', { name: 'akismet' } ); + expect( fetchChildren ).toHaveBeenCalledWith( 202, '1234567890', '/wp-content/', false ); + expect( screen.getByRole( 'checkbox', { name: 'Database' } ) ).toBeDisabled(); + expect( screen.getByTestId( 'environment-sync-submit-button' ) ).toBeDisabled(); + + await user.click( sourceFileCheckbox ); + + expect( screen.getByTestId( 'environment-sync-submit-button' ) ).toBeEnabled(); + } ); + + it( 'disables Production/Staging sync while a local workspace sync is running', async () => { + const user = userEvent.setup(); + featureFlagsMock.enableWorkspaces = true; + const productionSite = createSyncSite( { + id: 101, + localSiteId: selectedSite.id, + syncSupport: 'already-connected', + name: 'Linked Workspace', + url: 'https://production.example', + stagingSiteIds: [ 202 ], + } ); + const stagingSite = createSyncSite( { + id: 202, + localSiteId: selectedSite.id, + syncSupport: 'already-connected', + name: 'Linked Workspace Staging', + url: 'https://staging.example', + isStaging: true, + productionSiteId: 101, + } ); + mockWpcomSitesQuery( [ productionSite, stagingSite ] ); + store.dispatch( + syncOperationsActions.updatePushState( { + selectedSiteId: selectedSite.id, + remoteSiteId: productionSite.id, + state: { + status: { + key: 'uploading', + progress: 40, + message: 'Uploading site...', + }, + selectedSite, + remoteSiteUrl: productionSite.url, + }, + } ) + ); + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn( { + selectedSite, + sites: [ selectedSite ], + } ), + } ); + + await act( async () => renderWithProvider( ) ); + await user.click( screen.getByRole( 'tab', { name: 'Sync' } ) ); + + expect( screen.getByRole( 'button', { name: 'Push to Staging' } ) ).toBeDisabled(); + expect( screen.getByRole( 'button', { name: 'Pull to Production' } ) ).toBeDisabled(); + } ); } ); diff --git a/apps/studio/src/hooks/tests/use-add-site.test.tsx b/apps/studio/src/hooks/tests/use-add-site.test.tsx index 5af5a82b6b..1ef194db94 100644 --- a/apps/studio/src/hooks/tests/use-add-site.test.tsx +++ b/apps/studio/src/hooks/tests/use-add-site.test.tsx @@ -262,4 +262,64 @@ describe( 'useAddSite', () => { } ); expect( mockSetSelectedTab ).toHaveBeenCalledWith( 'sync' ); } ); + + it( 'should create a local site from a remote site setup action', async () => { + const remoteSite: SyncSite = { + id: 123, + localSiteId: '', + name: 'Remote Site', + url: 'https://example.com', + isStaging: false, + isPressable: false, + environmentType: null, + syncSupport: 'syncable', + lastPullTimestamp: null, + lastPushTimestamp: null, + }; + const createdSite = { + id: 'local-id', + name: 'Remote Site', + path: '/default/path', + wpVersion: 'latest', + phpVersion: '8.4', + }; + + mockCreateSite.mockImplementation( + ( path, name, version, customDomain, enableHttps, blueprint, phpVersion, callback ) => { + callback( createdSite ); + return Promise.resolve( createdSite ); + } + ); + + const { result } = renderHookWithProvider( () => useAddSite() ); + + await act( async () => { + await result.current.createSiteFromRemoteSite( remoteSite ); + } ); + + expect( mockCreateSite ).toHaveBeenCalledWith( + '/default/path', + 'Remote Site', + 'latest', + undefined, + false, + undefined, + '8.4', + expect.any( Function ), + true + ); + expect( mockConnectWpcomSites ).toHaveBeenCalledWith( [ + { + sites: [ remoteSite ], + localSiteId: createdSite.id, + }, + ] ); + expect( mockPullSiteThunk ).toHaveBeenCalledWith( { + client: mockClient, + connectedSite: remoteSite, + selectedSite: createdSite, + options: { optionsToSync: [ 'all' ] }, + } ); + expect( mockSetSelectedTab ).toHaveBeenCalledWith( 'sync' ); + } ); } ); diff --git a/apps/studio/src/hooks/use-add-site.ts b/apps/studio/src/hooks/use-add-site.ts index 5dc79b4f06..831b61ca8d 100644 --- a/apps/studio/src/hooks/use-add-site.ts +++ b/apps/studio/src/hooks/use-add-site.ts @@ -10,6 +10,7 @@ import { useContentTabs } from 'src/hooks/use-content-tabs'; import { useImportExport } from 'src/hooks/use-import-export'; import { useSiteDetails } from 'src/hooks/use-site-details'; import { getIpcApi } from 'src/lib/get-ipc-api'; +import { useFindAvailableSiteName } from 'src/modules/add-site/hooks/use-find-available-site-name'; import { useAppDispatch } from 'src/stores'; import { syncOperationsThunks } from 'src/stores/sync'; import { useConnectSiteMutation } from 'src/stores/sync/connected-sites'; @@ -53,6 +54,7 @@ export function useAddSite() { const { client } = useAuth(); const dispatch = useAppDispatch(); const { setSelectedTab } = useContentTabs(); + const findAvailableSiteName = useFindAvailableSiteName(); const [ fileForImport, setFileForImport ] = useState< File | null >( null ); const [ selectedBlueprint, setSelectedBlueprint ] = useState< Blueprint | undefined >(); const [ selectedRemoteSite, setSelectedRemoteSite ] = useState< SyncSite | undefined >(); @@ -315,9 +317,60 @@ export function useAddSite() { ] ); + const createSiteFromRemoteSite = useCallback( + async ( remoteSite: SyncSite ) => { + const siteName = await findAvailableSiteName( remoteSite.name ); + const proposedPath = await generateProposedPath( siteName ); + if ( proposedPath.error ) { + getIpcApi().showErrorMessageBox( { + title: __( 'Could not create local site' ), + message: proposedPath.error, + } ); + return; + } + + return createSite( + proposedPath.path, + siteName, + DEFAULT_WORDPRESS_VERSION, + undefined, + false, + undefined, + DEFAULT_PHP_VERSION, + async ( newSite ) => { + await connectSite( { site: remoteSite, localSiteId: newSite.id } ); + if ( client ) { + const pullOptions: SyncOption[] = [ 'all' ]; + void dispatch( + syncOperationsThunks.pullSite( { + client, + connectedSite: remoteSite, + selectedSite: newSite, + options: { optionsToSync: pullOptions }, + } ) + ); + setSelectedTab( 'sync' ); + } + }, + true + ); + }, + [ + __, + client, + connectSite, + createSite, + dispatch, + findAvailableSiteName, + generateProposedPath, + setSelectedTab, + ] + ); + return useMemo( () => ( { handleCreateSite, + createSiteFromRemoteSite, selectPath, generateProposedPath, deeplinkPhpVersion, @@ -350,6 +403,7 @@ export function useAddSite() { } ), [ handleCreateSite, + createSiteFromRemoteSite, selectPath, generateProposedPath, deeplinkPhpVersion, 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 8767034f80..6b38a93dbf 100644 --- a/apps/studio/src/modules/sync/components/sync-connected-sites.tsx +++ b/apps/studio/src/modules/sync/components/sync-connected-sites.tsx @@ -48,12 +48,16 @@ import { } from 'src/stores/sync/connected-sites'; import type { SyncSite } from '@studio/common/types/sync'; -const SyncConnectedSiteControls = ( { +export const SyncConnectedSiteControls = ( { connectedSite, selectedSite, + disabled = false, + disabledReason, }: { connectedSite: SyncSite; selectedSite: SiteDetails; + disabled?: boolean; + disabledReason?: string; } ) => { const { __ } = useI18n(); const isOffline = useOffline(); @@ -75,6 +79,10 @@ const SyncConnectedSiteControls = ( { ) ); const isAnySiteSyncing = isAnySitePulling || isAnySitePushing; + const isSyncActionDisabled = isAnySiteSyncing || disabled; + const syncDisabledText = disabled + ? disabledReason ?? __( 'This sync action is temporarily unavailable.' ) + : undefined; return (
    - { isAnySiteSyncing ? ( + { isSyncActionDisabled ? ( @@ -117,7 +126,7 @@ const SyncConnectedSiteControls = ( { '!text-frame-text hover:!text-frame-theme' ) } onClick={ () => setSyncDialogType( 'pull' ) } - disabled={ isAnySiteSyncing || isOffline } + disabled={ isSyncActionDisabled || isOffline } data-testid="sync-list-pull-button" > @@ -125,16 +134,17 @@ const SyncConnectedSiteControls = ( { ) } - { isAnySiteSyncing ? ( + { isSyncActionDisabled ? ( @@ -158,7 +168,7 @@ const SyncConnectedSiteControls = ( { '!text-frame-text hover:!text-frame-theme' ) } onClick={ () => setSyncDialogType( 'push' ) } - disabled={ isAnySiteSyncing || isOffline } + disabled={ isSyncActionDisabled || isOffline } data-testid="sync-list-push-button" > diff --git a/apps/studio/src/modules/sync/components/sync-dialog.tsx b/apps/studio/src/modules/sync/components/sync-dialog.tsx index ff35c0973d..b77a8b974d 100644 --- a/apps/studio/src/modules/sync/components/sync-dialog.tsx +++ b/apps/studio/src/modules/sync/components/sync-dialog.tsx @@ -1,11 +1,6 @@ import { PRESSABLE_PHP_VERSION } from '@studio/common/constants'; import { SYNC_PUSH_SIZE_LIMIT_GB } from '@studio/common/lib/sync/constants'; -import { - Icon, - SelectControl, - Notice, - __experimentalHeading as Heading, -} from '@wordpress/components'; +import { Icon, Notice } from '@wordpress/components'; import { createInterpolateElement } from '@wordpress/element'; import { sprintf, __ } from '@wordpress/i18n'; import { cautionFilled } from '@wordpress/icons'; @@ -14,8 +9,6 @@ import { format } from 'date-fns'; import { useState, useEffect, useCallback } from 'react'; import { ArrowIcon } from 'src/components/arrow-icon'; import Button from 'src/components/button'; -import { RightArrowIcon } from 'src/components/icons/right-arrow'; -import Modal from 'src/components/modal'; import { TwoColorProgressBar } from 'src/components/progress-bar'; import { Tooltip } from 'src/components/tooltip'; import { TreeView, TreeNode, updateNodeById } from 'src/components/tree-view'; @@ -25,7 +18,8 @@ import { cx } from 'src/lib/cx'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { getLocalizedLink } from 'src/lib/get-localized-link'; import { hasVersionMismatch } from 'src/modules/preview-site/lib/version-comparison'; -import { SiteNameBox } from 'src/modules/sync/components/site-name-box'; +import { SyncFilesSelectControl } from 'src/modules/sync/components/sync-files-select-control'; +import { SyncModalShell } from 'src/modules/sync/components/sync-modal-shell'; import { useSelectedItemsPushSize } from 'src/modules/sync/hooks/use-selected-items-push-size'; import { useSyncDialogTexts } from 'src/modules/sync/hooks/use-sync-dialog-texts'; import { useTopLevelSyncTree } from 'src/modules/sync/hooks/use-top-level-sync-tree'; @@ -224,20 +218,13 @@ export function SyncDialog( { ).length; const [ warningsExpanded, setWarningsExpanded ] = useState( true ); - const localSiteName = ; - const remoteSiteName = ; - - let syncFrom, syncTo, syncFromText, syncToText, tooltipNoRewindId; + let sourceSite, destinationSite, tooltipNoRewindId; if ( type === 'push' ) { - syncFrom = localSiteName; - syncTo = remoteSiteName; - syncFromText = localSite.name; - syncToText = remoteSite.name; + sourceSite = { name: localSite.name, envType: 'studio' as const }; + destinationSite = { name: remoteSite.name, envType: siteEnv }; } else { - syncFrom = remoteSiteName; - syncTo = localSiteName; - syncFromText = remoteSite.name; - syncToText = localSite.name; + sourceSite = { name: remoteSite.name, envType: siteEnv }; + destinationSite = { name: localSite.name, envType: 'studio' as const }; tooltipNoRewindId = createInterpolateElement( __( 'Selecting individual items to pull will be enabled automatically once your first backup is complete.
    Wait a few minutes or run a full sync in the meantime.' @@ -320,140 +307,16 @@ export function SyncDialog( { }; return ( - -
    -
    { syncTexts.description }
    -
    - - { /* translators: first %s is the source site name, second %s is the destination site name */ } - { sprintf( __( 'From %s to %s' ), syncFromText, syncToText ) } - - -
    - - { syncTexts.subtitleSelector } - - -
    - { type === 'pull' && isLoadingRewindId && } - { type === 'push' && isLoadingLocalFileTree && } - { ! isLoadingRewindId && ! isLoadingLocalFileTree && ( - <> -
    - handleExpanderChange( value === 'true' ) } - disabled={ isErrorRewindId } - __next40pxDefaultSize - __nextHasNoMarginBottom - aria-label={ __( 'Select files and folders to sync' ) } - className="h-9 select-minimal" - /> -
    - { - if ( nodeId === 'filesAndFolders' && showAllFiles && rewindId ) { - const backupUrl = `https://wordpress.com/backup/${ remoteSite.url.replace( - /^https?:\/\//, - '' - ) }`; - const backupDate = format( parseInt( rewindId ) * 1000, 'MMM d, y, h:mm a' ); - return ( -
    - { sprintf( __( 'Content from the latest backup: %s.' ), backupDate ) }{ ' ' } - -
    - ); - } - return null; - } } - renderEmptyContent={ ( nodeId, node ) => { - if ( nodeId === 'wp-content' && type === 'push' && localFileTreeError ) { - return ( -
    - { __( - 'Could not load files. Please close and reopen this dialog to try again.' - ) } -
    - ); - } - if ( - ( nodeId === 'wp-content' && type === 'pull' && remoteFileTreeError ) || - node.hasError - ) { - return ( -
    - { __( - 'Error retrieving remote files and directories. Please close and reopen this dialog to try again.' - ) } -
    - ); - } - return ( -
    - { __( 'Empty' ) } -
    - ); - } } - /> - - ) } -
    -
    - -
    + description={ syncTexts.description } + subtitle={ syncTexts.subtitleSelector } + source={ sourceSite } + destination={ destinationSite } + onRequestClose={ onRequestClose } + contentStyle={ { paddingBottom: getBottomPadding() } } + footer={ + <> { type === 'push' && (
    + + } + > + +
    + { type === 'pull' && isLoadingRewindId && } + { type === 'push' && isLoadingLocalFileTree && } + { ! isLoadingRewindId && ! isLoadingLocalFileTree && ( + <> +
    + handleExpanderChange( value === 'specific' ) } + disabled={ isErrorRewindId } + /> +
    + { + if ( nodeId === 'filesAndFolders' && showAllFiles && rewindId ) { + const backupUrl = `https://wordpress.com/backup/${ remoteSite.url.replace( + /^https?:\/\//, + '' + ) }`; + const backupDate = format( parseInt( rewindId ) * 1000, 'MMM d, y, h:mm a' ); + return ( +
    + { sprintf( __( 'Content from the latest backup: %s.' ), backupDate ) }{ ' ' } + +
    + ); + } + return null; + } } + renderEmptyContent={ ( nodeId, node ) => { + if ( nodeId === 'wp-content' && type === 'push' && localFileTreeError ) { + return ( +
    + { __( + 'Could not load files. Please close and reopen this dialog to try again.' + ) } +
    + ); + } + if ( + ( nodeId === 'wp-content' && type === 'pull' && remoteFileTreeError ) || + node.hasError + ) { + return ( +
    + { __( + 'Error retrieving remote files and directories. Please close and reopen this dialog to try again.' + ) } +
    + ); + } + return ( +
    + { __( 'Empty' ) } +
    + ); + } } + /> + + ) }
    -
    -
    +
    + ); } diff --git a/apps/studio/src/modules/sync/components/sync-files-select-control.tsx b/apps/studio/src/modules/sync/components/sync-files-select-control.tsx new file mode 100644 index 0000000000..28cd4474c7 --- /dev/null +++ b/apps/studio/src/modules/sync/components/sync-files-select-control.tsx @@ -0,0 +1,45 @@ +import { SelectControl } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +export type SyncFilesSelectionMode = 'all' | 'specific'; + +type SyncFilesSelectControlProps = { + value: SyncFilesSelectionMode; + onChange: ( value: SyncFilesSelectionMode ) => void; + disabled?: boolean; + showSpecificOption?: boolean; +}; + +export function SyncFilesSelectControl( { + value, + onChange, + disabled, + showSpecificOption = true, +}: SyncFilesSelectControlProps ) { + return ( + onChange( nextValue as SyncFilesSelectionMode ) } + disabled={ disabled } + __next40pxDefaultSize + __nextHasNoMarginBottom + aria-label={ __( 'Select files and folders to sync' ) } + className="h-9 select-minimal" + /> + ); +} diff --git a/apps/studio/src/modules/sync/components/sync-modal-shell.tsx b/apps/studio/src/modules/sync/components/sync-modal-shell.tsx new file mode 100644 index 0000000000..59167750f0 --- /dev/null +++ b/apps/studio/src/modules/sync/components/sync-modal-shell.tsx @@ -0,0 +1,88 @@ +import { __experimentalHeading as Heading } from '@wordpress/components'; +import { sprintf, __ } from '@wordpress/i18n'; +import { type CSSProperties, type ReactNode } from 'react'; +import { RightArrowIcon } from 'src/components/icons/right-arrow'; +import Modal from 'src/components/modal'; +import { SiteNameBox } from 'src/modules/sync/components/site-name-box'; +import type { EnvironmentType } from 'src/modules/sync/lib/environment-utils'; + +type SyncModalSite = { + name: string; + envType: EnvironmentType | 'studio'; +}; + +type SyncModalShellProps = { + title: string; + description: string; + subtitle: string; + source: SyncModalSite; + destination: SyncModalSite; + onRequestClose: () => void; + children: ReactNode; + footer: ReactNode; + contentClassName?: string; + contentStyle?: CSSProperties; +}; + +export function SyncModalShell( { + title, + description, + subtitle, + source, + destination, + onRequestClose, + children, + footer, + contentClassName, + contentStyle, +}: SyncModalShellProps ) { + return ( + +
    +
    { description }
    +
    + + { /* translators: first %s is the source site name, second %s is the destination site name */ } + { sprintf( __( 'From %s to %s' ), source.name, destination.name ) } + + +
    + + { subtitle } + + { children } +
    + { footer } +
    +
    +
    + ); +} diff --git a/apps/studio/src/modules/sync/components/workspace-sync-panel.tsx b/apps/studio/src/modules/sync/components/workspace-sync-panel.tsx new file mode 100644 index 0000000000..a242430540 --- /dev/null +++ b/apps/studio/src/modules/sync/components/workspace-sync-panel.tsx @@ -0,0 +1,918 @@ +import { CheckboxControl, Icon, Spinner } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { cloudDownload, cloudUpload } from '@wordpress/icons'; +import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; +import { ArrowIcon } from 'src/components/arrow-icon'; +import Button from 'src/components/button'; +import { Tooltip } from 'src/components/tooltip'; +import { TreeView, updateNodeById, type TreeNode } from 'src/components/tree-view'; +import { useAddSite } from 'src/hooks/use-add-site'; +import { cx } from 'src/lib/cx'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import { getLocalizedLink } from 'src/lib/get-localized-link'; +import { SyncConnectedSiteControls } from 'src/modules/sync/components/sync-connected-sites'; +import { + SyncFilesSelectControl, + type SyncFilesSelectionMode, +} from 'src/modules/sync/components/sync-files-select-control'; +import { SyncModalShell } from 'src/modules/sync/components/sync-modal-shell'; +import { TreeViewLoadingSkeleton } from 'src/modules/sync/components/tree-view-loading-skeleton'; +import { useWorkspaceSelection } from 'src/modules/workspaces'; +import { useAppDispatch, useI18nLocale, useRootSelector } from 'src/stores'; +import { + stagingSyncSelectors, + stagingSyncThunks, + syncOperationsSelectors, + type StagingSyncDirection, + type StagingSyncOption, + type StagingSyncOptions, +} from 'src/stores/sync'; +import { useConnectSiteMutation } from 'src/stores/sync/connected-sites'; +import { useLatestRewindId, useRemoteFileTree } from 'src/stores/sync/sync-hooks'; +import type { SyncSite } from '@studio/common/types/sync'; +import type { StudioWorkspace, WorkspaceTargetId } from 'src/modules/workspaces/types'; + +type WorkspaceSyncPanelContentProps = { + workspace: StudioWorkspace; + selectedTargetId?: WorkspaceTargetId; +}; + +type EnvironmentSyncDialogProps = { + direction: StagingSyncDirection; + productionSite: SyncSite; + stagingSite: SyncSite; + onClose: () => void; +}; + +const ENVIRONMENT_FILE_SYNC_OPTIONS: StagingSyncOption[] = [ + 'contents', + 'themes', + 'plugins', + 'uploads', + 'roots', +]; + +const createEnvironmentFileTree = (): TreeNode[] => [ + { + id: 'wp-content', + name: 'wp-content', + label: 'wp-content', + checked: false, + indeterminate: false, + type: 'folder', + children: [], + expanded: true, + }, +]; + +function collectSelectedPathIds( nodes: TreeNode[] | undefined ): string[] { + if ( ! nodes?.length ) { + return []; + } + + return nodes.flatMap( ( node ) => { + const selectedPathIds: string[] = []; + if ( node.checked && node.pathId ) { + selectedPathIds.push( node.pathId ); + } + if ( node.children?.length && ( node.checked || node.indeterminate ) ) { + selectedPathIds.push( ...collectSelectedPathIds( node.children ) ); + } + return selectedPathIds; + } ); +} + +function isRemoteConnectedToLocal( remoteSite: SyncSite | undefined, localSite?: SiteDetails ) { + return Boolean( + remoteSite && + localSite && + remoteSite.localSiteId === localSite.id && + remoteSite.syncSupport === 'already-connected' + ); +} + +function getRemoteSiteLocalSetupState( remoteSite: SyncSite ) { + switch ( remoteSite.syncSupport ) { + case 'syncable': + return { + canUseLocalSetup: true, + }; + case 'needs-upgrade': + return { + canUseLocalSetup: false, + description: __( 'Upgrade this site plan before creating or connecting a local version.' ), + buttonLabel: __( 'Upgrade plan' ), + actionUrl: `https://wordpress.com/plans/${ remoteSite.id }`, + }; + case 'needs-transfer': + return { + canUseLocalSetup: false, + description: __( 'Enable hosting features before creating or connecting a local version.' ), + buttonLabel: __( 'Enable hosting' ), + actionUrl: `https://wordpress.com/hosting-features/${ remoteSite.id }`, + }; + case 'missing-permissions': + return { + canUseLocalSetup: false, + description: __( + 'You need permission to manage this site before creating or connecting a local version.' + ), + buttonLabel: __( 'Missing permissions' ), + }; + case 'deleted': + return { + canUseLocalSetup: false, + description: __( 'This WordPress.com site is deleted.' ), + buttonLabel: __( 'Deleted' ), + }; + case 'unsupported': + return { + canUseLocalSetup: false, + description: __( 'This site does not support Studio sync.' ), + buttonLabel: __( 'Unsupported' ), + }; + case 'already-connected': + return { + canUseLocalSetup: false, + description: __( 'This target is already connected to another local site.' ), + buttonLabel: __( 'Already connected' ), + }; + } +} + +function getProductionStagingSetupState( productionSite: SyncSite ) { + if ( productionSite.canManageOptions === false ) { + return { + canCreateStagingSite: false, + description: __( + 'You need permission to manage this production site before creating staging.' + ), + buttonLabel: __( 'Missing permissions' ), + }; + } + + if ( productionSite.hasStagingSiteFeature === false ) { + return { + canCreateStagingSite: false, + description: __( 'This site plan does not include staging sites.' ), + buttonLabel: __( 'Upgrade plan' ), + actionUrl: `https://wordpress.com/plans/${ productionSite.id }`, + }; + } + + if ( productionSite.isWpcomAtomic === false ) { + return { + canCreateStagingSite: false, + description: __( 'Enable hosting features before creating a staging site.' ), + buttonLabel: __( 'Enable hosting' ), + actionUrl: `https://wordpress.com/hosting-features/${ productionSite.id }`, + }; + } + + return { + canCreateStagingSite: true, + description: __( 'Create a staging target from Production before syncing environments.' ), + buttonLabel: __( 'Create staging site' ), + }; +} + +function useLocalRemoteSyncState( localSite?: SiteDetails, remoteSite?: SyncSite ) { + const isPulling = useRootSelector( + syncOperationsSelectors.selectIsSiteIdPulling( localSite?.id ?? '', remoteSite?.id ) + ); + const isPushing = useRootSelector( + syncOperationsSelectors.selectIsSiteIdPushing( localSite?.id ?? '', remoteSite?.id ) + ); + + return { + isSyncing: isPulling || isPushing, + }; +} + +function EnvironmentSyncDialog( { + direction, + productionSite, + stagingSite, + onClose, +}: EnvironmentSyncDialogProps ) { + const locale = useI18nLocale(); + const dispatch = useAppDispatch(); + const [ includeFiles, setIncludeFiles ] = useState( false ); + const [ includeDatabase, setIncludeDatabase ] = useState( false ); + const [ fileSelectionMode, setFileSelectionMode ] = useState< SyncFilesSelectionMode >( 'all' ); + const [ fileTree, setFileTree ] = useState< TreeNode[] >( () => createEnvironmentFileTree() ); + const [ fileTreeError, setFileTreeError ] = useState< Error | null >( null ); + const [ isLoadingFileTree, setIsLoadingFileTree ] = useState( false ); + const [ isSubmitting, setIsSubmitting ] = useState( false ); + const isPull = direction === 'pull'; + const title = isPull ? __( 'Pull from Staging' ) : __( 'Push to Staging' ); + const actionLabel = isPull ? __( 'Pull' ) : __( 'Push' ); + const sourceSite = isPull ? stagingSite : productionSite; + const destinationSite = isPull ? productionSite : stagingSite; + const isUsingSpecificFileSelection = includeFiles && fileSelectionMode === 'specific'; + const selectedPathIds = useMemo( () => collectSelectedPathIds( fileTree ), [ fileTree ] ); + const selectedOptions: StagingSyncOptions = isUsingSpecificFileSelection + ? { + types: 'paths', + include_paths: selectedPathIds, + exclude_paths: [], + } + : [ + ...( includeFiles ? ENVIRONMENT_FILE_SYNC_OPTIONS : [] ), + ...( includeDatabase ? ( [ 'sqls' ] as const ) : [] ), + ]; + const hasSelectedOptions = Array.isArray( selectedOptions ) + ? selectedOptions.length > 0 + : selectedOptions.include_paths.length > 0; + const syncDescription = isPull + ? __( + "Pulling will overwrite your production site's selected files and database with a copy from your staging site. Unchecked items will not be changed." + ) + : __( + "Pushing will overwrite your staging site's selected files and database with a copy from your production site. Unchecked items will not be changed." + ); + const subtitle = isPull + ? __( 'What would you like to pull?' ) + : __( 'What would you like to push?' ); + const docsLabel = isPull + ? __( 'Read more about environment pull' ) + : __( 'Read more about environment push' ); + const sourceEnvironment = isPull ? 'staging' : 'production'; + const destinationEnvironment = isPull ? 'production' : 'staging'; + const { + rewindId, + isLoading: isLoadingRewindId, + isError: isErrorRewindId, + } = useLatestRewindId( sourceSite.id, { + skip: ! isUsingSpecificFileSelection, + } ); + const { fetchChildren } = useRemoteFileTree(); + const isSubmitDisabled = + ! hasSelectedOptions || isSubmitting || isLoadingRewindId || isLoadingFileTree; + + useEffect( () => { + setFileTree( createEnvironmentFileTree() ); + setFileTreeError( null ); + }, [ sourceSite.id ] ); + + useEffect( () => { + if ( ! isUsingSpecificFileSelection || ! rewindId ) { + return; + } + + let isCancelled = false; + const loadSourceFileTree = async () => { + setIsLoadingFileTree( true ); + setFileTreeError( null ); + try { + const children = await fetchChildren( sourceSite.id, rewindId, '/wp-content/', false ); + if ( ! isCancelled ) { + setFileTree( ( previousFileTree ) => + updateNodeById( previousFileTree, 'wp-content', { children } ) + ); + } + } catch ( error ) { + if ( ! isCancelled ) { + setFileTreeError( error instanceof Error ? error : new Error( String( error ) ) ); + } + } finally { + if ( ! isCancelled ) { + setIsLoadingFileTree( false ); + } + } + }; + + void loadSourceFileTree(); + + return () => { + isCancelled = true; + }; + }, [ fetchChildren, isUsingSpecificFileSelection, rewindId, sourceSite.id ] ); + + const handleFileSelectionModeChange = ( nextMode: SyncFilesSelectionMode ) => { + setFileSelectionMode( nextMode ); + if ( nextMode === 'specific' ) { + setIncludeFiles( true ); + setIncludeDatabase( false ); + } + }; + + const handleIncludeFilesChange = ( checked: boolean ) => { + setIncludeFiles( checked ); + if ( ! checked ) { + setFileSelectionMode( 'all' ); + setFileTree( createEnvironmentFileTree() ); + } + }; + + const handleExpandFileTreeNode = useCallback( + async ( node: TreeNode ) => { + if ( ! rewindId || ! node.path || node.children?.length ) { + return; + } + + try { + const children = await fetchChildren( sourceSite.id, rewindId, node.path, node.checked ); + setFileTree( ( previousFileTree ) => + updateNodeById( previousFileTree, node.id, { + children, + loading: false, + hasError: false, + } ) + ); + } catch ( error ) { + setFileTree( ( previousFileTree ) => + updateNodeById( previousFileTree, node.id, { + children: [], + loading: false, + hasError: true, + } ) + ); + } + }, + [ fetchChildren, rewindId, sourceSite.id ] + ); + + const runEnvironmentSync = async ( allowWooSync = false ) => { + if ( ! hasSelectedOptions ) { + return; + } + + setIsSubmitting( true ); + const result = await dispatch( + stagingSyncThunks.startStagingSiteSync( { + productionSite, + stagingSite, + direction, + options: selectedOptions, + allowWooSync, + } ) + ); + setIsSubmitting( false ); + + if ( stagingSyncThunks.startStagingSiteSync.fulfilled.match( result ) ) { + onClose(); + return; + } + + const error = result.payload; + if ( isPull && includeDatabase && error?.code === 'rest_sqls_option_not_supported' ) { + const { response } = await getIpcApi().showMessageBox( { + message: __( 'Pull production database from a WooCommerce staging site?' ), + detail: __( + 'WooCommerce data can be sensitive. Confirm that the staging database should overwrite production, then Studio will retry this sync.' + ), + buttons: [ __( 'Retry with database' ), __( 'Cancel' ) ], + cancelId: 1, + } ); + + if ( response === 0 ) { + await runEnvironmentSync( true ); + } + return; + } + + getIpcApi().showErrorMessageBox( { + title: __( 'Could not sync staging site' ), + message: error?.message ?? __( 'The staging sync could not be started.' ), + } ); + }; + + return ( + +
    + +
    +
    + + +
    +
    + } + > +
    +
    +
    + +
    +
    + +
    +
    + { isUsingSpecificFileSelection && ( +
    + { isLoadingRewindId || isLoadingFileTree ? ( + + ) : isErrorRewindId || fileTreeError ? ( +
    + { __( + 'Could not load source site files. Please close and reopen this dialog to try again.' + ) } +
    + ) : ( + { + if ( nodeId === 'wp-content' && fileTreeError ) { + return ( +
    + { __( + 'Could not load source site files. Please close and reopen this dialog to try again.' + ) } +
    + ); + } + if ( node.hasError ) { + return ( +
    + { __( + 'Error retrieving remote files and directories. Please collapse and expand this folder to try again.' + ) } +
    + ); + } + return ( +
    + { __( 'Empty' ) } +
    + ); + } } + /> + ) } +
    + ) } +
    + +
    +
    + + ); +} + +function WorkspaceSyncRow( { + label, + description, + active, + children, +}: { + label: string; + description: string; + active?: boolean; + children: ReactNode; +} ) { + return ( +
    +
    +
    { label }
    +
    { description }
    +
    +
    { children }
    +
    + ); +} + +function LocalRemoteSyncRow( { + label, + localSite, + remoteSite, + remoteTargetId, + disabled, + active, + onCreateLocalSite, + onConnectRemoteSite, + isCreatingLocalSite, + isConnectingSite, +}: { + label: string; + localSite?: SiteDetails; + remoteSite?: SyncSite; + remoteTargetId: Extract< WorkspaceTargetId, 'production' | 'staging' >; + disabled?: boolean; + active?: boolean; + onCreateLocalSite: ( remoteSite: SyncSite ) => void; + onConnectRemoteSite: ( remoteSite: SyncSite, localSite: SiteDetails ) => void; + isCreatingLocalSite?: boolean; + isConnectingSite?: boolean; +} ) { + const isConnected = isRemoteConnectedToLocal( remoteSite, localSite ); + const missingDescription = + remoteTargetId === 'production' + ? __( 'Connect or create a Production target before syncing this link.' ) + : __( 'Connect or create a Staging target before syncing this link.' ); + const disabledReason = __( 'Wait for the Production/Staging sync to finish.' ); + const localSetupState = remoteSite ? getRemoteSiteLocalSetupState( remoteSite ) : undefined; + const canUseLocalSetup = localSetupState?.canUseLocalSetup; + const canConnectRemoteSite = Boolean( localSite && remoteSite && canUseLocalSetup ); + const description = + isConnected && remoteSite + ? remoteSite.url + : remoteSite && ! canUseLocalSetup && localSetupState + ? localSetupState.description ?? missingDescription + : remoteSite && ! localSite + ? __( 'Create a local copy of this target before syncing.' ) + : remoteSite && localSite + ? __( 'Connect this target to the local site before syncing.' ) + : missingDescription; + + return ( + +
    + { isConnected && localSite && remoteSite ? ( + + ) : remoteSite && ! localSite && canUseLocalSetup ? ( + + + + ) : remoteSite && localSite && canConnectRemoteSite ? ( + + + + ) : remoteSite && localSetupState?.actionUrl ? ( + + + + ) : ( + + ) } +
    +
    + ); +} + +function EnvironmentSyncRow( { + productionSite, + stagingSite, + disabled, + active, + onOpenDialog, + onCreateStagingSite, + isCreatingStagingSite, +}: { + productionSite?: SyncSite; + stagingSite?: SyncSite; + disabled: boolean; + active?: boolean; + onOpenDialog: ( direction: StagingSyncDirection ) => void; + onCreateStagingSite: ( productionSite: SyncSite ) => void; + isCreatingStagingSite?: boolean; +} ) { + const stagingSyncState = useRootSelector( + stagingSyncSelectors.selectState( productionSite?.id ) + ); + const isEnvironmentSyncing = useRootSelector( + stagingSyncSelectors.selectIsProductionSiteSyncing( productionSite?.id ) + ); + const hasKnownStagingSite = Boolean( productionSite?.stagingSiteIds?.length ); + const stagingSetupState = productionSite + ? getProductionStagingSetupState( productionSite ) + : undefined; + const canCreateStagingSite = + Boolean( productionSite && ! stagingSite && ! hasKnownStagingSite ) && + stagingSetupState?.canCreateStagingSite; + const disabledReason = disabled + ? __( 'Wait for the local sync operation to finish.' ) + : ! productionSite + ? __( 'Production site details are required before creating or syncing staging.' ) + : productionSite && ! stagingSite && hasKnownStagingSite + ? __( 'A staging site is linked to this production site but its details have not loaded yet.' ) + : undefined; + const description = ! productionSite + ? __( 'Add a Production target before creating or syncing staging.' ) + : ! stagingSite + ? hasKnownStagingSite + ? __( 'Staging site details are still loading.' ) + : stagingSetupState?.description ?? + __( 'Create a staging target from Production before syncing environments.' ) + : stagingSyncState?.status === 'failed' + ? stagingSyncState.error?.message ?? __( 'Environment sync failed.' ) + : isEnvironmentSyncing + ? __( 'Environment sync is running.' ) + : stagingSyncState?.status === 'completed' + ? __( 'Last environment sync completed.' ) + : __( 'Copy content between production and staging.' ); + + return ( + Staging' ) } + description={ description } + active={ active } + > + +
    + { isEnvironmentSyncing && } + { productionSite && stagingSite ? ( + <> + + + + ) : productionSite && canCreateStagingSite ? ( + + ) : productionSite && ! stagingSite && stagingSetupState?.actionUrl ? ( + + ) : ( + + ) } +
    +
    +
    + ); +} + +export function WorkspaceSyncPanelContent( { + workspace, + selectedTargetId, +}: WorkspaceSyncPanelContentProps ) { + const dispatch = useAppDispatch(); + const { createSiteFromRemoteSite } = useAddSite(); + const { refreshWorkspaces } = useWorkspaceSelection(); + const [ connectSite ] = useConnectSiteMutation(); + const [ environmentSyncDirection, setEnvironmentSyncDirection ] = + useState< StagingSyncDirection | null >( null ); + const [ creatingLocalSiteId, setCreatingLocalSiteId ] = useState< number | null >( null ); + const [ connectingRemoteSiteId, setConnectingRemoteSiteId ] = useState< number | null >( null ); + const [ creatingStagingProductionSiteId, setCreatingStagingProductionSiteId ] = useState< + number | null + >( null ); + const localSite = workspace.targets.local?.site; + const productionSite = workspace.targets.production?.site; + const stagingSite = workspace.targets.staging?.site; + const localProductionSyncState = useLocalRemoteSyncState( localSite, productionSite ); + const localStagingSyncState = useLocalRemoteSyncState( localSite, stagingSite ); + const isEnvironmentSyncing = useRootSelector( + stagingSyncSelectors.selectIsProductionSiteSyncing( productionSite?.id ) + ); + const isAnyLocalRemoteSyncing = + localProductionSyncState.isSyncing || localStagingSyncState.isSyncing; + const shouldShowLocalProductionRow = Boolean( localSite || productionSite ); + const shouldShowLocalStagingRow = Boolean( stagingSite && ( localSite || ! productionSite ) ); + const shouldShowEnvironmentRow = Boolean( productionSite || stagingSite ); + + const handleCreateLocalSite = useCallback( + async ( remoteSite: SyncSite ) => { + setCreatingLocalSiteId( remoteSite.id ); + try { + await createSiteFromRemoteSite( remoteSite ); + refreshWorkspaces(); + } catch ( error ) { + getIpcApi().showErrorMessageBox( { + title: __( 'Could not create local site' ), + message: + error instanceof Error + ? error.message + : __( 'The local site could not be created from this WordPress.com site.' ), + } ); + } finally { + setCreatingLocalSiteId( null ); + } + }, + [ createSiteFromRemoteSite, refreshWorkspaces ] + ); + + const handleConnectRemoteSite = useCallback( + async ( remoteSite: SyncSite, site: SiteDetails ) => { + setConnectingRemoteSiteId( remoteSite.id ); + try { + const result = await connectSite( { site: remoteSite, localSiteId: site.id } ); + if ( 'error' in result ) { + throw result.error; + } + refreshWorkspaces(); + } catch ( error ) { + getIpcApi().showErrorMessageBox( { + title: __( 'Could not connect site' ), + message: + error instanceof Error + ? error.message + : __( 'The WordPress.com site could not be connected to this local site.' ), + } ); + } finally { + setConnectingRemoteSiteId( null ); + } + }, + [ connectSite, refreshWorkspaces ] + ); + + const handleCreateStagingSite = useCallback( + async ( site: SyncSite ) => { + setCreatingStagingProductionSiteId( site.id ); + const result = await dispatch( + stagingSyncThunks.createStagingSite( { productionSite: site } ) + ); + setCreatingStagingProductionSiteId( null ); + + if ( stagingSyncThunks.createStagingSite.fulfilled.match( result ) ) { + getIpcApi().showNotification( { + title: site.name, + body: __( 'Staging site created' ), + } ); + refreshWorkspaces(); + return; + } + + getIpcApi().showErrorMessageBox( { + title: __( 'Could not create staging site' ), + message: + result.payload?.message ?? + __( 'The staging site could not be created for this production site.' ), + } ); + }, + [ dispatch, refreshWorkspaces ] + ); + + useEffect( () => { + if ( ! productionSite?.id || ! stagingSite?.id ) { + return; + } + + void dispatch( + stagingSyncThunks.fetchStagingSiteSyncState( { productionSiteId: productionSite.id } ) + ); + }, [ dispatch, productionSite?.id, stagingSite?.id ] ); + + useEffect( () => { + if ( ! productionSite?.id || ! stagingSite?.id || ! isEnvironmentSyncing ) { + return; + } + + const intervalId = window.setInterval( () => { + void dispatch( + stagingSyncThunks.fetchStagingSiteSyncState( { productionSiteId: productionSite.id } ) + ); + }, 3000 ); + + return () => window.clearInterval( intervalId ); + }, [ dispatch, isEnvironmentSyncing, productionSite?.id, stagingSite?.id ] ); + + if ( + ! shouldShowLocalProductionRow && + ! shouldShowLocalStagingRow && + ! shouldShowEnvironmentRow + ) { + return ( +
    +
    + { __( 'No workspace sync links are available yet.' ) } +
    +
    + ); + } + + return ( +
    +
    +

    { __( 'Sync' ) }

    +
    + { shouldShowLocalProductionRow && ( + Production' ) } + localSite={ localSite } + remoteSite={ productionSite } + remoteTargetId="production" + disabled={ isEnvironmentSyncing } + active={ selectedTargetId === 'production' } + onCreateLocalSite={ handleCreateLocalSite } + onConnectRemoteSite={ handleConnectRemoteSite } + isCreatingLocalSite={ creatingLocalSiteId === productionSite?.id } + isConnectingSite={ connectingRemoteSiteId === productionSite?.id } + /> + ) } + { shouldShowLocalStagingRow && ( + Staging' ) } + localSite={ localSite } + remoteSite={ stagingSite } + remoteTargetId="staging" + disabled={ isEnvironmentSyncing } + active={ selectedTargetId === 'staging' } + onCreateLocalSite={ handleCreateLocalSite } + onConnectRemoteSite={ handleConnectRemoteSite } + isCreatingLocalSite={ creatingLocalSiteId === stagingSite?.id } + isConnectingSite={ connectingRemoteSiteId === stagingSite?.id } + /> + ) } + { shouldShowEnvironmentRow && ( + + ) } +
    +
    + { environmentSyncDirection && productionSite && stagingSite && ( + setEnvironmentSyncDirection( null ) } + /> + ) } +
    + ); +} diff --git a/apps/studio/src/modules/sync/tests/index.test.tsx b/apps/studio/src/modules/sync/tests/index.test.tsx index cdd8c82ae4..d211e477e2 100644 --- a/apps/studio/src/modules/sync/tests/index.test.tsx +++ b/apps/studio/src/modules/sync/tests/index.test.tsx @@ -575,7 +575,7 @@ describe( 'ContentTabSync', () => { // Open specific files and folders selector const select = screen.getByRole( 'combobox', { name: 'Select files and folders to sync' } ); - fireEvent.change( select, { target: { value: 'true' } } ); + fireEvent.change( select, { target: { value: 'specific' } } ); // Check plugins and uploads const pluginsCheckbox = screen.getByRole( 'checkbox', { name: 'plugins' } ); @@ -654,7 +654,7 @@ describe( 'ContentTabSync', () => { await screen.findByText( 'Pull from Production' ); const select = screen.getByRole( 'combobox', { name: 'Select files and folders to sync' } ); - fireEvent.change( select, { target: { value: 'true' } } ); + fireEvent.change( select, { target: { value: 'specific' } } ); const pluginsCheckbox = screen.getByRole( 'checkbox', { name: 'plugins' } ); fireEvent.click( pluginsCheckbox ); const filesAndFoldersCheckbox = screen.getByRole( 'checkbox', { name: 'Files and folders' } ); @@ -711,7 +711,7 @@ describe( 'ContentTabSync', () => { await screen.findByText( 'Push to Production' ); const select = screen.getByRole( 'combobox', { name: 'Select files and folders to sync' } ); - fireEvent.change( select, { target: { value: 'true' } } ); + fireEvent.change( select, { target: { value: 'specific' } } ); const pluginsCheckbox = screen.getByRole( 'checkbox', { name: 'plugins' } ); fireEvent.click( pluginsCheckbox ); diff --git a/apps/studio/src/modules/workspaces/components/workspace-content-shell.tsx b/apps/studio/src/modules/workspaces/components/workspace-content-shell.tsx index 2e7d0cb291..c5acbe2b78 100644 --- a/apps/studio/src/modules/workspaces/components/workspace-content-shell.tsx +++ b/apps/studio/src/modules/workspaces/components/workspace-content-shell.tsx @@ -13,6 +13,7 @@ import { useImportExport } from 'src/hooks/use-import-export'; import { useSiteDetails } from 'src/hooks/use-site-details'; import { cx } from 'src/lib/cx'; import { ContentTabSync } from 'src/modules/sync'; +import { WorkspaceSyncPanelContent } from 'src/modules/sync/components/workspace-sync-panel'; import { getDefaultWorkspaceTabId, getWorkspaceTabIds, @@ -51,32 +52,6 @@ function EmptyWorkspaceSelection() { ); } -function WorkspaceSyncPlaceholder( { workspace }: { workspace: StudioWorkspace } ) { - return ( -
    -
    -

    { __( 'Sync' ) }

    -
    - { workspace.syncLinks.length ? ( - workspace.syncLinks.map( ( link ) => ( -
    - { link.source } <-> { link.target } -
    - ) ) - ) : ( -
    - { __( 'No workspace sync links are available yet.' ) } -
    - ) } -
    -
    -
    - ); -} - function SettingsRow( { label, value }: { label: string; value?: string | number | null } ) { return (
    @@ -494,10 +469,18 @@ export function WorkspaceContentShell() { ) ) } { name === 'sync' && - ( localContextSite ? ( + ( selectedWorkspace.syncLinks.length ? ( + + ) : localContextSite ? ( ) : ( - + ) ) } { name === 'settings' && ( localContextSite ? ( diff --git a/apps/studio/src/modules/workspaces/hooks/use-sidebar-workspaces.ts b/apps/studio/src/modules/workspaces/hooks/use-sidebar-workspaces.ts index 87d302c444..5440febfba 100644 --- a/apps/studio/src/modules/workspaces/hooks/use-sidebar-workspaces.ts +++ b/apps/studio/src/modules/workspaces/hooks/use-sidebar-workspaces.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useAuth } from 'src/hooks/use-auth'; import { useFeatureFlags } from 'src/hooks/use-feature-flags'; import { useSiteDetails } from 'src/hooks/use-site-details'; @@ -13,6 +13,7 @@ export function useSidebarWorkspaces() { const { enableWorkspaces } = useFeatureFlags(); const [ connectedSites, setConnectedSites ] = useState< SyncSite[] >( [] ); const [ isLoadingConnectedSites, setIsLoadingConnectedSites ] = useState( false ); + const [ connectedSitesRefreshKey, setConnectedSitesRefreshKey ] = useState( 0 ); const connectedSiteIds = useMemo( () => connectedSites.map( ( site ) => site.id ), [ connectedSites ] @@ -22,6 +23,7 @@ export function useSidebarWorkspaces() { data: wpcomSitesData, isFetching: isFetchingWpcomSites, isLoading: isLoadingWpcomSites, + refetch: refetchWpcomSites, } = useGetWpComSitesQuery( { connectedSiteIds, @@ -31,6 +33,13 @@ export function useSidebarWorkspaces() { { skip: ! shouldLoadRemoteSites } ); + const refreshWorkspaces = useCallback( () => { + setConnectedSitesRefreshKey( ( key ) => key + 1 ); + if ( shouldLoadRemoteSites ) { + void refetchWpcomSites(); + } + }, [ refetchWpcomSites, shouldLoadRemoteSites ] ); + useEffect( () => { if ( ! shouldLoadRemoteSites ) { setConnectedSites( [] ); @@ -62,7 +71,7 @@ export function useSidebarWorkspaces() { return () => { isCurrent = false; }; - }, [ shouldLoadRemoteSites ] ); + }, [ connectedSitesRefreshKey, shouldLoadRemoteSites ] ); const wpcomSites = useMemo( () => ( shouldLoadRemoteSites ? wpcomSitesData?.sites ?? [] : [] ), @@ -85,6 +94,7 @@ export function useSidebarWorkspaces() { sidebarWorkspaces, wpcomSites, connectedSites, + refreshWorkspaces, isLoading: shouldLoadRemoteSites && ( isLoadingConnectedSites || isLoadingWpcomSites || isFetchingWpcomSites ), diff --git a/apps/studio/src/modules/workspaces/hooks/use-workspace-selection.tsx b/apps/studio/src/modules/workspaces/hooks/use-workspace-selection.tsx index 0174bb2935..202f22ab15 100644 --- a/apps/studio/src/modules/workspaces/hooks/use-workspace-selection.tsx +++ b/apps/studio/src/modules/workspaces/hooks/use-workspace-selection.tsx @@ -18,6 +18,7 @@ type WorkspaceSelectionContextValue = { selectWorkspace: ( workspaceId: string ) => void; selectedTabId?: TabName; selectWorkspaceTab: ( workspaceId: string, tabId: TabName ) => void; + refreshWorkspaces: () => void; }; const WorkspaceSelectionContext = createContext< WorkspaceSelectionContextValue | undefined >( @@ -54,7 +55,12 @@ function writeSavedTabId( workspaceId: string, tabId: TabName ) { export function WorkspaceSelectionProvider( { children }: { children: ReactNode } ) { const { selectedSite, setSelectedSiteId } = useSiteDetails(); - const { enableWorkspaces, sidebarWorkspaces: workspaces, isLoading } = useSidebarWorkspaces(); + const { + enableWorkspaces, + sidebarWorkspaces: workspaces, + isLoading, + refreshWorkspaces, + } = useSidebarWorkspaces(); const [ explicitSelectedWorkspaceId, setExplicitSelectedWorkspaceId ] = useState< string >(); const [ selectedTabs, setSelectedTabs ] = useState< Record< string, TabName > >( {} ); const selectedSiteId = selectedSite?.id; @@ -134,10 +140,12 @@ export function WorkspaceSelectionProvider( { children }: { children: ReactNode selectWorkspace, selectedTabId, selectWorkspaceTab, + refreshWorkspaces, }; }, [ enableWorkspaces, isLoading, + refreshWorkspaces, selectWorkspaceTab, selectWorkspace, selectedTabId, diff --git a/apps/studio/src/stores/index.ts b/apps/studio/src/stores/index.ts index a703b28645..f93936b2b3 100644 --- a/apps/studio/src/stores/index.ts +++ b/apps/studio/src/stores/index.ts @@ -20,7 +20,7 @@ import { refreshSnapshots, snapshotActions, } from 'src/stores/snapshot-slice'; -import { syncReducer, syncOperationsActions } from 'src/stores/sync'; +import { stagingSyncReducer, syncReducer, syncOperationsActions } from 'src/stores/sync'; import { connectedSitesApi, connectedSitesReducer } from 'src/stores/sync/connected-sites'; import { syncOperationsReducer, @@ -41,6 +41,7 @@ export type RootState = { onboarding: ReturnType< typeof onboardingReducer >; snapshot: ReturnType< typeof snapshotReducer >; sync: ReturnType< typeof syncReducer >; + stagingSync: ReturnType< typeof stagingSyncReducer >; connectedSitesApi: ReturnType< typeof connectedSitesApi.reducer >; connectedSites: ReturnType< typeof connectedSitesReducer >; syncOperations: ReturnType< typeof syncOperationsReducer >; @@ -332,6 +333,7 @@ export const rootReducer = combineReducers( { onboarding: onboardingReducer, snapshot: snapshotReducer, sync: syncReducer, + stagingSync: stagingSyncReducer, syncOperations: syncOperationsReducer, wordpressVersionsApi: wordpressVersionsApi.reducer, wpcomApi: wpcomApi.reducer, diff --git a/apps/studio/src/stores/sync/index.ts b/apps/studio/src/stores/sync/index.ts index 0480de18d1..c7bb9fb558 100644 --- a/apps/studio/src/stores/sync/index.ts +++ b/apps/studio/src/stores/sync/index.ts @@ -12,6 +12,13 @@ export { syncOperationsSelectors, syncOperationsThunks, } from './sync-operations-slice'; +export { + STAGING_SYNC_OPTION_TOKENS, + stagingSyncActions, + stagingSyncReducer, + stagingSyncSelectors, + stagingSyncThunks, +} from './staging-sync-slice'; export type { SyncBackupState, PullSiteOptions, @@ -19,3 +26,11 @@ export type { SyncPushState, PushStates, } from './sync-operations-slice'; +export type { + StagingSyncDirection, + StagingSyncOption, + StagingSyncOptions, + StagingSyncPathOptions, + StagingSyncState, + StagingSyncStatus, +} from './staging-sync-slice'; diff --git a/apps/studio/src/stores/sync/staging-sync-slice.test.ts b/apps/studio/src/stores/sync/staging-sync-slice.test.ts new file mode 100644 index 0000000000..6957b30561 --- /dev/null +++ b/apps/studio/src/stores/sync/staging-sync-slice.test.ts @@ -0,0 +1,335 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { + createStagingSite, + fetchStagingSiteSyncState, + stagingSyncReducer, + startStagingSiteSync, + type StagingSyncState, +} from 'src/stores/sync/staging-sync-slice'; +import type { SyncSite } from '@studio/common/types/sync'; + +const { mockGetWpcomClient } = vi.hoisted( () => ( { + mockGetWpcomClient: vi.fn(), +} ) ); + +vi.mock( 'src/stores/wpcom-api', () => ( { + getWpcomClient: mockGetWpcomClient, +} ) ); + +const createSyncSite = ( overrides: Partial< SyncSite > = {} ): SyncSite => ( { + id: 101, + localSiteId: '', + name: 'Production Site', + url: 'https://production.example', + isStaging: false, + isPressable: false, + syncSupport: 'syncable', + lastPullTimestamp: null, + lastPushTimestamp: null, + ...overrides, +} ); + +describe( 'staging sync reducer', () => { + beforeEach( () => { + mockGetWpcomClient.mockReset(); + } ); + + it( 'tracks production-scoped staging sync state when a sync starts', () => { + const productionSite = createSyncSite(); + const stagingSite = createSyncSite( { + id: 202, + isStaging: true, + productionSiteId: 101, + } ); + const action = startStagingSiteSync.pending( 'request-id', { + productionSite, + stagingSite, + direction: 'push', + options: [ 'themes', 'plugins' ], + } ); + + const state = stagingSyncReducer( undefined, action ); + + expect( state.states[ 101 ] ).toMatchObject( { + productionSiteId: 101, + stagingSiteId: 202, + status: 'started', + direction: 'push', + options: [ 'themes', 'plugins' ], + } ); + } ); + + it( 'does not let an empty sync-state response erase an active local state', () => { + const activeState: StagingSyncState = { + productionSiteId: 101, + stagingSiteId: 202, + status: 'started', + direction: 'push', + }; + + const state = stagingSyncReducer( + { states: { 101: activeState } }, + fetchStagingSiteSyncState.fulfilled( + { + productionSiteId: 101, + status: 'idle', + }, + 'request-id', + { productionSiteId: 101 } + ) + ); + + expect( state.states[ 101 ] ).toEqual( activeState ); + } ); + + it( 'records completed environment sync state from polling', () => { + const state = stagingSyncReducer( + undefined, + fetchStagingSiteSyncState.fulfilled( + { + productionSiteId: 101, + stagingSiteId: 202, + status: 'completed', + direction: 'pull', + completedAt: '2026-05-14T12:00:00+00:00', + }, + 'request-id', + { productionSiteId: 101 } + ) + ); + + expect( state.states[ 101 ] ).toMatchObject( { + productionSiteId: 101, + stagingSiteId: 202, + status: 'completed', + direction: 'pull', + completedAt: '2026-05-14T12:00:00+00:00', + } ); + } ); + + it( 'posts production to staging sync requests to the staging endpoint', async () => { + const post = vi.fn().mockResolvedValue( { success: true } ); + mockGetWpcomClient.mockReturnValue( { + req: { post }, + } ); + const store = configureStore( { + reducer: stagingSyncReducer, + } ); + const productionSite = createSyncSite(); + const stagingSite = createSyncSite( { + id: 202, + isStaging: true, + productionSiteId: 101, + } ); + + await store.dispatch( + startStagingSiteSync( { + productionSite, + stagingSite, + direction: 'push', + options: [ 'themes', 'plugins' ], + } ) + ); + + expect( post ).toHaveBeenCalledWith( { + apiNamespace: 'wpcom/v2', + path: '/sites/101/staging-site/push-to-staging/202', + body: { + options: [ 'themes', 'plugins' ], + }, + } ); + } ); + + it( 'posts full content sync requests to the staging endpoint', async () => { + const post = vi.fn().mockResolvedValue( { success: true } ); + mockGetWpcomClient.mockReturnValue( { + req: { post }, + } ); + const store = configureStore( { + reducer: stagingSyncReducer, + } ); + const productionSite = createSyncSite(); + const stagingSite = createSyncSite( { + id: 202, + isStaging: true, + productionSiteId: 101, + } ); + + await store.dispatch( + startStagingSiteSync( { + productionSite, + stagingSite, + direction: 'pull', + options: [ 'contents', 'roots', 'sqls' ], + } ) + ); + + expect( post ).toHaveBeenCalledWith( { + apiNamespace: 'wpcom/v2', + path: '/sites/101/staging-site/pull-from-staging/202', + body: { + options: [ 'contents', 'roots', 'sqls' ], + }, + } ); + } ); + + it( 'posts granular file path sync requests to the staging endpoint', async () => { + const post = vi.fn().mockResolvedValue( { success: true } ); + mockGetWpcomClient.mockReturnValue( { + req: { post }, + } ); + const store = configureStore( { + reducer: stagingSyncReducer, + } ); + const productionSite = createSyncSite(); + const stagingSite = createSyncSite( { + id: 202, + isStaging: true, + productionSiteId: 101, + } ); + + await store.dispatch( + startStagingSiteSync( { + productionSite, + stagingSite, + direction: 'pull', + options: { + types: 'paths', + include_paths: [ 'cjI6,ZjI6Lw==' ], + exclude_paths: [], + }, + } ) + ); + + expect( post ).toHaveBeenCalledWith( { + apiNamespace: 'wpcom/v2', + path: '/sites/101/staging-site/pull-from-staging/202', + body: { + options: { + types: 'paths', + include_paths: [ 'cjI6,ZjI6Lw==' ], + exclude_paths: [], + }, + }, + } ); + } ); + + it( 'posts staging-site creation requests to the production staging endpoint', async () => { + const post = vi.fn().mockResolvedValue( { + id: 202, + name: 'Production Site Staging', + url: 'https://staging.example', + user_has_permission: true, + } ); + mockGetWpcomClient.mockReturnValue( { + req: { post }, + } ); + const store = configureStore( { + reducer: stagingSyncReducer, + } ); + const productionSite = createSyncSite(); + + const result = await store.dispatch( createStagingSite( { productionSite } ) ); + + expect( createStagingSite.fulfilled.match( result ) ).toBe( true ); + expect( post ).toHaveBeenCalledWith( { + apiNamespace: 'wpcom/v2', + path: '/sites/101/staging-site', + } ); + expect( result.payload ).toMatchObject( { + id: 202, + url: 'https://staging.example', + } ); + } ); + + it( 'fetches production-scoped staging sync state', async () => { + const get = vi.fn().mockResolvedValue( { + status: 'completed', + staging_blog_id: 202, + production_blog_id: 101, + direction: 'pull', + options: { types: 'contents,roots,themes,plugins' }, + } ); + mockGetWpcomClient.mockReturnValue( { + req: { get }, + } ); + const store = configureStore( { + reducer: stagingSyncReducer, + } ); + + await store.dispatch( fetchStagingSiteSyncState( { productionSiteId: 101 } ) ); + + expect( get ).toHaveBeenCalledWith( { + apiNamespace: 'wpcom/v2', + path: '/sites/101/staging-site/sync-state', + } ); + expect( store.getState().states[ 101 ] ).toMatchObject( { + status: 'completed', + stagingSiteId: 202, + direction: 'pull', + options: [ 'contents', 'roots', 'themes', 'plugins' ], + } ); + } ); + + it( 'normalizes numeric timestamps from staging sync state responses', async () => { + const get = vi.fn().mockResolvedValue( { + status: 'in-progress', + staging_blog_id: 202, + production_blog_id: 101, + direction: 'pull', + started_at: 1778766552, + updated_at: 1778766560, + } ); + mockGetWpcomClient.mockReturnValue( { + req: { get }, + } ); + const store = configureStore( { + reducer: stagingSyncReducer, + } ); + + await store.dispatch( fetchStagingSiteSyncState( { productionSiteId: 101 } ) ); + + expect( store.getState().states[ 101 ] ).toMatchObject( { + status: 'in-progress', + startedAt: '2026-05-14T13:49:12.000Z', + updatedAt: '2026-05-14T13:49:20.000Z', + } ); + } ); + + it( 'preserves WooCommerce database sync errors for confirm-and-retry handling', async () => { + const error = { + code: 'rest_sqls_option_not_supported', + message: 'WooCommerce database sync requires confirmation.', + status: 422, + }; + const post = vi.fn().mockRejectedValue( error ); + mockGetWpcomClient.mockReturnValue( { + req: { post }, + } ); + const store = configureStore( { + reducer: stagingSyncReducer, + } ); + const productionSite = createSyncSite(); + const stagingSite = createSyncSite( { + id: 202, + isStaging: true, + productionSiteId: 101, + } ); + + const result = await store.dispatch( + startStagingSiteSync( { + productionSite, + stagingSite, + direction: 'pull', + options: [ 'sqls' ], + } ) + ); + + expect( startStagingSiteSync.rejected.match( result ) ).toBe( true ); + expect( result.payload ).toMatchObject( { + code: 'rest_sqls_option_not_supported', + status: 422, + } ); + expect( result.payload ).not.toHaveProperty( 'raw' ); + } ); +} ); diff --git a/apps/studio/src/stores/sync/staging-sync-slice.ts b/apps/studio/src/stores/sync/staging-sync-slice.ts new file mode 100644 index 0000000000..f1ad83bc71 --- /dev/null +++ b/apps/studio/src/stores/sync/staging-sync-slice.ts @@ -0,0 +1,452 @@ +import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; +import * as Sentry from '@sentry/electron/renderer'; +import { __ } from '@wordpress/i18n'; +import { z } from 'zod'; +import { getWpcomClient } from 'src/stores/wpcom-api'; +import type { SyncSite } from '@studio/common/types/sync'; +import type { RootState } from 'src/stores'; + +export const STAGING_SYNC_OPTION_TOKENS = [ + 'sqls', + 'contents', + 'themes', + 'plugins', + 'uploads', + 'roots', +] as const; +export type StagingSyncOption = ( typeof STAGING_SYNC_OPTION_TOKENS )[ number ]; +export type StagingSyncPathOptions = { + types: 'paths'; + include_paths: string[]; + exclude_paths?: string[]; +}; +export type StagingSyncOptions = StagingSyncOption[] | StagingSyncPathOptions; +export type StagingSyncDirection = 'push' | 'pull'; +export type StagingSyncStatus = + | 'idle' + | 'started' + | 'in-progress' + | 'completed' + | 'failed' + | 'no-staging' + | 'timed-out'; + +type StagingSyncApiError = { + code?: string; + message: string; + status?: number; +}; + +export type StagingSyncState = { + productionSiteId: number; + stagingSiteId?: number; + status: StagingSyncStatus; + direction?: StagingSyncDirection; + restoreId?: number; + lastRestoreId?: number; + startedAt?: string; + completedAt?: string; + updatedAt?: string; + options?: StagingSyncOptions; + error?: StagingSyncApiError; +}; + +type StagingSyncSliceState = { + states: Record< number, StagingSyncState >; +}; + +const apiTimestampSchema = z + .union( [ z.string(), z.number() ] ) + .nullable() + .optional() + .transform( ( value ) => { + if ( value === undefined || value === null || value === '' ) { + return undefined; + } + + if ( typeof value === 'string' ) { + return value; + } + + const milliseconds = value > 10_000_000_000 ? value : value * 1000; + const date = new Date( milliseconds ); + return Number.isNaN( date.getTime() ) ? String( value ) : date.toISOString(); + } ); + +const stagingSyncStateResponseSchema = z.object( { + status: z.string(), + staging_blog_id: z.number().optional(), + restore_id: z.number().optional(), + last_restore_id: z.number().optional(), + production_blog_id: z.number(), + started_at: apiTimestampSchema, + completed_at: apiTimestampSchema, + direction: z.enum( [ 'push', 'pull' ] ).optional(), + options: z.unknown().optional(), + updated_at: apiTimestampSchema, +} ); + +const successResponseSchema = z.object( { + success: z.boolean(), +} ); + +const createdStagingSiteResponseSchema = z + .object( { + id: z.number(), + name: z.string().optional(), + url: z.string().optional(), + user_has_permission: z.boolean().optional(), + } ) + .passthrough(); + +export type CreatedStagingSite = z.infer< typeof createdStagingSiteResponseSchema >; + +const initialState: StagingSyncSliceState = { + states: {}, +}; + +const ACTIVE_STAGING_SYNC_STATUSES = new Set< StagingSyncStatus >( [ 'started', 'in-progress' ] ); + +function getApiErrorStatus( error: unknown ) { + if ( error && typeof error === 'object' ) { + const status = ( error as { status?: unknown } ).status; + if ( typeof status === 'number' ) { + return status; + } + + const statusCode = ( error as { statusCode?: unknown } ).statusCode; + if ( typeof statusCode === 'number' ) { + return statusCode; + } + + const dataStatus = ( error as { data?: { status?: unknown } } ).data?.status; + if ( typeof dataStatus === 'number' ) { + return dataStatus; + } + } + + return undefined; +} + +export function getStagingSyncApiError( error: unknown ): StagingSyncApiError { + if ( error instanceof z.ZodError ) { + return { + code: 'invalid_sync_state_response', + message: __( 'The staging sync response was not in the expected format.' ), + }; + } + + if ( error && typeof error === 'object' ) { + const code = ( error as { code?: unknown } ).code; + const message = ( error as { message?: unknown } ).message; + + return { + code: typeof code === 'string' ? code : undefined, + message: + typeof message === 'string' && message.length > 0 + ? message + : __( 'The staging sync could not be completed.' ), + status: getApiErrorStatus( error ), + }; + } + + return { + message: + error instanceof Error ? error.message : __( 'The staging sync could not be completed.' ), + }; +} + +function normalizeStatus( status: string ): StagingSyncStatus { + if ( status === 'started' || status === 'in-progress' || status === 'completed' ) { + return status; + } + + return status === 'failed' ? 'failed' : 'in-progress'; +} + +function normalizeOptions( options: unknown ): StagingSyncOptions | undefined { + if ( Array.isArray( options ) ) { + return options.filter( ( option ): option is StagingSyncOption => + ( STAGING_SYNC_OPTION_TOKENS as readonly string[] ).includes( option ) + ); + } + + if ( options && typeof options === 'object' ) { + const { + types, + include_paths: includePaths, + exclude_paths: excludePaths, + } = options as { + types?: unknown; + include_paths?: unknown; + exclude_paths?: unknown; + }; + if ( types === 'paths' && Array.isArray( includePaths ) ) { + return { + types, + include_paths: includePaths.filter( + ( includePath ): includePath is string => typeof includePath === 'string' + ), + exclude_paths: Array.isArray( excludePaths ) + ? excludePaths.filter( + ( excludePath ): excludePath is string => typeof excludePath === 'string' + ) + : undefined, + }; + } + + if ( typeof types === 'string' ) { + return normalizeOptions( types.split( ',' ) ); + } + } + + return undefined; +} + +export const startStagingSiteSync = createAsyncThunk< + { productionSiteId: number }, + { + productionSite: SyncSite; + stagingSite: SyncSite; + direction: StagingSyncDirection; + options: StagingSyncOptions; + allowWooSync?: boolean; + }, + { rejectValue: StagingSyncApiError } +>( + 'stagingSync/start', + async ( + { productionSite, stagingSite, direction, options, allowWooSync }, + { rejectWithValue } + ) => { + const wpcomClient = getWpcomClient(); + if ( ! wpcomClient ) { + return rejectWithValue( { + code: 'not_authenticated', + message: __( 'Log in to WordPress.com to sync staging sites.' ), + status: 401, + } ); + } + + try { + const path = + direction === 'push' + ? `/sites/${ productionSite.id }/staging-site/push-to-staging/${ stagingSite.id }` + : `/sites/${ productionSite.id }/staging-site/pull-from-staging/${ stagingSite.id }`; + const response = await wpcomClient.req.post( { + apiNamespace: 'wpcom/v2', + path, + body: { + options, + ...( allowWooSync ? { allow_woo_sync: true } : {} ), + }, + } ); + successResponseSchema.parse( response ); + + return { productionSiteId: productionSite.id }; + } catch ( error ) { + Sentry.captureException( error ); + console.error( error ); + return rejectWithValue( getStagingSyncApiError( error ) ); + } + } +); + +export const fetchStagingSiteSyncState = createAsyncThunk< + StagingSyncState, + { productionSiteId: number }, + { rejectValue: StagingSyncApiError } +>( 'stagingSync/fetchState', async ( { productionSiteId }, { rejectWithValue } ) => { + const wpcomClient = getWpcomClient(); + if ( ! wpcomClient ) { + return rejectWithValue( { + code: 'not_authenticated', + message: __( 'Log in to WordPress.com to sync staging sites.' ), + status: 401, + } ); + } + + try { + const response = await wpcomClient.req.get( { + apiNamespace: 'wpcom/v2', + path: `/sites/${ productionSiteId }/staging-site/sync-state`, + } ); + + if ( ! response ) { + return { + productionSiteId, + status: 'idle', + }; + } + + const parsed = stagingSyncStateResponseSchema.parse( response ); + + return { + productionSiteId: parsed.production_blog_id, + stagingSiteId: parsed.staging_blog_id, + status: normalizeStatus( parsed.status ), + direction: parsed.direction, + restoreId: parsed.restore_id, + lastRestoreId: parsed.last_restore_id, + startedAt: parsed.started_at, + completedAt: parsed.completed_at, + updatedAt: parsed.updated_at, + options: normalizeOptions( parsed.options ), + }; + } catch ( error ) { + const apiError = getStagingSyncApiError( error ); + if ( apiError.status === 204 ) { + return { + productionSiteId, + status: 'idle', + }; + } + + if ( apiError.status === 404 || apiError.code === 'no_staging_sites' ) { + return { + productionSiteId, + status: 'no-staging', + error: apiError, + }; + } + + Sentry.captureException( error ); + console.error( error ); + return rejectWithValue( apiError ); + } +} ); + +export const createStagingSite = createAsyncThunk< + CreatedStagingSite, + { productionSite: SyncSite }, + { rejectValue: StagingSyncApiError } +>( 'stagingSync/createStagingSite', async ( { productionSite }, { rejectWithValue } ) => { + const wpcomClient = getWpcomClient(); + if ( ! wpcomClient ) { + return rejectWithValue( { + code: 'not_authenticated', + message: __( 'Log in to WordPress.com to create a staging site.' ), + status: 401, + } ); + } + + try { + const response = await wpcomClient.req.post( { + apiNamespace: 'wpcom/v2', + path: `/sites/${ productionSite.id }/staging-site`, + } ); + + return createdStagingSiteResponseSchema.parse( response ); + } catch ( error ) { + Sentry.captureException( error ); + console.error( error ); + return rejectWithValue( getStagingSyncApiError( error ) ); + } +} ); + +const stagingSyncSlice = createSlice( { + name: 'stagingSync', + initialState, + reducers: { + clearStagingSyncState: ( state, action: PayloadAction< { productionSiteId: number } > ) => { + delete state.states[ action.payload.productionSiteId ]; + }, + markStagingSyncTimedOut: ( state, action: PayloadAction< { productionSiteId: number } > ) => { + const currentState = state.states[ action.payload.productionSiteId ]; + if ( currentState && ACTIVE_STAGING_SYNC_STATUSES.has( currentState.status ) ) { + currentState.status = 'timed-out'; + } + }, + }, + extraReducers: ( builder ) => { + builder + .addCase( startStagingSiteSync.pending, ( state, action ) => { + const { productionSite, stagingSite, direction, options } = action.meta.arg; + state.states[ productionSite.id ] = { + productionSiteId: productionSite.id, + stagingSiteId: stagingSite.id, + status: 'started', + direction, + options, + startedAt: new Date().toISOString(), + }; + } ) + .addCase( startStagingSiteSync.rejected, ( state, action ) => { + const { productionSite, stagingSite, direction, options } = action.meta.arg; + state.states[ productionSite.id ] = { + productionSiteId: productionSite.id, + stagingSiteId: stagingSite.id, + status: 'failed', + direction, + options, + error: action.payload ?? getStagingSyncApiError( action.error ), + }; + } ) + .addCase( fetchStagingSiteSyncState.fulfilled, ( state, action ) => { + const currentState = state.states[ action.payload.productionSiteId ]; + if ( + action.payload.status === 'idle' && + currentState && + ACTIVE_STAGING_SYNC_STATUSES.has( currentState.status ) + ) { + return; + } + + state.states[ action.payload.productionSiteId ] = { + ...currentState, + ...action.payload, + }; + } ) + .addCase( fetchStagingSiteSyncState.rejected, ( state, action ) => { + const { productionSiteId } = action.meta.arg; + state.states[ productionSiteId ] = { + ...state.states[ productionSiteId ], + productionSiteId, + status: 'failed', + error: action.payload ?? getStagingSyncApiError( action.error ), + }; + } ); + }, +} ); + +export const stagingSyncActions = stagingSyncSlice.actions; +export const stagingSyncReducer = stagingSyncSlice.reducer; + +export const stagingSyncSelectors = { + selectState: ( productionSiteId?: number ) => ( state: RootState ) => + productionSiteId ? state.stagingSync.states[ productionSiteId ] : undefined, + selectIsProductionSiteSyncing: ( productionSiteId?: number ) => ( state: RootState ) => { + const stagingSyncState = productionSiteId + ? state.stagingSync.states[ productionSiteId ] + : undefined; + return stagingSyncState ? ACTIVE_STAGING_SYNC_STATUSES.has( stagingSyncState.status ) : false; + }, + selectIsRemoteSiteEnvironmentSyncing: ( siteId?: number ) => ( state: RootState ) => { + if ( ! siteId ) { + return false; + } + + return Object.values( state.stagingSync.states ).some( + ( stagingSyncState ) => + ACTIVE_STAGING_SYNC_STATUSES.has( stagingSyncState.status ) && + ( stagingSyncState.productionSiteId === siteId || + stagingSyncState.stagingSiteId === siteId ) + ); + }, + selectRemoteSiteEnvironmentSyncState: ( siteId?: number ) => ( state: RootState ) => { + if ( ! siteId ) { + return undefined; + } + + return Object.values( state.stagingSync.states ).find( + ( stagingSyncState ) => + stagingSyncState.productionSiteId === siteId || stagingSyncState.stagingSiteId === siteId + ); + }, +}; + +export const stagingSyncThunks = { + startStagingSiteSync, + fetchStagingSiteSyncState, + createStagingSite, +}; From 0dd582416db21274fe445a664c609a2af97b7374 Mon Sep 17 00:00:00 2001 From: dereksmart Date: Tue, 19 May 2026 08:25:25 -0400 Subject: [PATCH 7/7] Refine workspace live-site shell What changed. - Reuse the shared Studio tab structure for workspace targets so Assistant stays in the same IA instead of using a separate remote-only tab set. - Move the active Local/Staging/Production context into the workspace header and keep the URL bar focused on the preview URL. - Add live-site overview and settings surfaces backed by WordPress.com site/theme/settings data. - Rework workspace sync rows to reuse the connected-site sync presentation for local/live links and add the production/staging environment sync affordance in that same structure. - Keep preview state workspace-scoped, default previews closed, preserve opened preview size across target switches, and prevent the closed preview controls from hiding the Assistant tab. - Keep Dolly workspace chats active while passing the currently selected preview target context. What stayed unchanged / intentionally deferred. - The enableWorkspaces=false path stays on the existing local-site sidebar and content behavior. - Local-only workflows remain backed by the existing local tab panels and legacy local assistant. - Dolly still uses the existing remote transport shape; deeper target-aware backend routing is not solved here. - This does not make live Import / Export or Previews fully functional; unsupported target content remains a workspace-shell affordance for now. Tradeoffs and risks. - The live overview/settings panels are read-oriented and depend on available WordPress.com metadata, so some fields may be absent or partial for specific sites. - The production/staging sync UI leans on existing environment-sync APIs and status conventions instead of introducing a new workspace sync model. - The closed preview URL bar reserves tab-strip space using a scoped CSS variable so the tab layout remains stable, but the final visual balance may still need product polish. Verification. - npx eslint --fix - npm test -- apps/studio/src/components/tests/main-sidebar.test.tsx apps/studio/src/components/tests/site-content-tabs.test.tsx apps/studio/src/modules/workspaces/components/workspace-dolly-assistant.test.tsx apps/studio/src/modules/workspaces/lib/build-studio-workspaces.test.ts - npm run typecheck - git diff --cached --check --- .../components/tests/main-sidebar.test.tsx | 78 +++- .../tests/site-content-tabs.test.tsx | 309 ++++++++++++-- apps/studio/src/index.css | 4 + .../sync/components/sync-connected-sites.tsx | 9 +- .../sync/components/workspace-sync-panel.tsx | 223 ++++++---- .../components/workspace-content-shell.tsx | 65 +-- .../workspace-dolly-assistant.test.tsx | 11 + .../components/workspace-dolly-assistant.tsx | 11 + .../components/workspace-header.tsx | 143 +++++-- .../components/workspace-live-site-panels.tsx | 382 ++++++++++++++++++ .../components/workspace-preview.tsx | 190 +++++---- .../components/workspace-sidebar-row.tsx | 61 ++- .../hooks/use-workspace-selection.tsx | 47 ++- apps/studio/src/modules/workspaces/index.ts | 8 - .../modules/workspaces/lib/workspace-tabs.ts | 31 -- apps/studio/src/stores/sync/wpcom-sites.ts | 112 +++++ 16 files changed, 1366 insertions(+), 318 deletions(-) create mode 100644 apps/studio/src/modules/workspaces/components/workspace-live-site-panels.tsx delete mode 100644 apps/studio/src/modules/workspaces/lib/workspace-tabs.ts diff --git a/apps/studio/src/components/tests/main-sidebar.test.tsx b/apps/studio/src/components/tests/main-sidebar.test.tsx index 9db2b80173..f753068ff3 100644 --- a/apps/studio/src/components/tests/main-sidebar.test.tsx +++ b/apps/studio/src/components/tests/main-sidebar.test.tsx @@ -1,3 +1,4 @@ +import { configureStore } from '@reduxjs/toolkit'; import { render, act, screen, waitFor } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { Provider } from 'react-redux'; @@ -11,11 +12,15 @@ import { getWorkspaceDollyConversationState, writeWorkspaceDollyConversationState, } from 'src/modules/workspaces/lib/dolly/session'; +import { startWorkspaceDollyTurn } from 'src/modules/workspaces/lib/dolly/turns'; import { WORKSPACE_DOLLY_AGENT_ID, type WorkspaceDollyConversationState, } from 'src/modules/workspaces/lib/dolly/types'; import { store } from 'src/stores'; +import { installedAppsApi } from 'src/stores/installed-apps-api'; +import { stagingSyncActions, stagingSyncThunks } from 'src/stores/sync'; +import { testActions, testReducer } from 'src/stores/tests/utils/test-reducer'; import type { SyncSite } from '@studio/common/types/sync'; const featureFlagsMock = vi.hoisted( () => ( { @@ -33,6 +38,8 @@ const ipcApiMock = vi.hoisted( () => ( { getUserTerminal: vi.fn().mockResolvedValue( 'terminal' ), setWindowControlVisibility: vi.fn(), setupAppMenu: vi.fn(), + addSyncOperation: vi.fn(), + clearSyncOperation: vi.fn(), } ) ); const useGetWpComSitesQueryMock = vi.hoisted( () => vi.fn() ); @@ -90,6 +97,8 @@ vi.mock( 'src/stores/sync/wpcom-sites', async () => { }; } ); +store.replaceReducer( testReducer ); + const createLocalSite = ( overrides: Partial< SiteDetails > = {} ): SiteDetails => ( { name: 'test-1', @@ -219,9 +228,9 @@ const enableWorkspaceSidebar = ( { mockWpcomSitesQuery( wpcomSites ); }; -const renderWithProvider = ( children: React.ReactElement ) => { +const renderWithProvider = ( children: React.ReactElement, reduxStore = store ) => { return render( - + { children } @@ -234,6 +243,7 @@ describe( 'MainSidebar Footer', () => { vi.clearAllMocks(); localStorage.clear(); clearWorkspaceDollyAssistantStateCacheForTests(); + store.dispatch( testActions.resetState() ); featureFlagsMock.enableWorkspaces = false; siteDetailsMocked.sites = defaultLocalSites(); siteDetailsMocked.selectedSite = site2; @@ -272,6 +282,7 @@ describe( 'MainSidebar Site Menu', () => { vi.clearAllMocks(); localStorage.clear(); clearWorkspaceDollyAssistantStateCacheForTests(); + store.dispatch( testActions.resetState() ); featureFlagsMock.enableWorkspaces = false; siteDetailsMocked.sites = defaultLocalSites(); siteDetailsMocked.selectedSite = site2; @@ -321,6 +332,8 @@ describe( 'MainSidebar Workspace Site Menu', () => { vi.clearAllMocks(); localStorage.clear(); clearWorkspaceDollyAssistantStateCacheForTests(); + store.dispatch( testActions.resetState() ); + store.dispatch( stagingSyncActions.clearStagingSyncState( { productionSiteId: 101 } ) ); ipcApiMock.getConnectedWpcomSites.mockResolvedValue( [] ); mockWpcomSitesQuery(); } ); @@ -482,6 +495,67 @@ describe( 'MainSidebar Workspace Site Menu', () => { expect( screen.queryByRole( 'button', { name: 'L' } ) ).not.toBeInTheDocument(); } ); + it( 'shows workspace activity when the assistant is thinking', async () => { + const productionSite = createSyncSite( { + id: 101, + name: 'Remote Only', + url: 'https://remote-only.example', + } ); + enableWorkspaceSidebar( { + wpcomSites: [ productionSite ], + } ); + startWorkspaceDollyTurn( { + workspaceId: 'studio-workspace:wpcom:101', + conversationId: 'remote-only-chat', + abortController: new AbortController(), + } ); + + await act( async () => renderWithProvider( ) ); + + expect( await screen.findByLabelText( 'Remote Only assistant is thinking' ) ).toBeVisible(); + } ); + + it( 'shows workspace activity while a workspace sync is running', async () => { + const syncStore = configureStore( { + reducer: testReducer, + middleware: ( getDefaultMiddleware ) => + getDefaultMiddleware().concat( installedAppsApi.middleware ), + } ); + const productionSite = createSyncSite( { + id: 101, + name: 'Business Plan', + url: 'https://business-plan.example', + stagingSiteIds: [ 202 ], + } ); + const stagingSite = createSyncSite( { + id: 202, + name: 'Business Plan Staging', + url: 'https://business-plan-staging.example', + isStaging: true, + productionSiteId: 101, + } ); + enableWorkspaceSidebar( { + wpcomSites: [ productionSite, stagingSite ], + } ); + syncStore.dispatch( + stagingSyncThunks.startStagingSiteSync.pending( 'request-id', { + productionSite, + stagingSite, + direction: 'push', + options: [ 'themes' ], + } ) + ); + expect( syncStore.getState().stagingSync.states[ 101 ] ).toMatchObject( { + status: 'started', + stagingSiteId: 202, + } ); + + await act( async () => renderWithProvider( , syncStore ) ); + + expect( await screen.findByRole( 'button', { name: 'Business Plan' } ) ).toBeVisible(); + expect( screen.getByLabelText( 'Business Plan sync is in progress' ) ).toBeVisible(); + } ); + it( 'renders recent chats under their workspace and selects that workspace chat', async () => { const user = userEvent.setup(); const productionSite = createSyncSite( { diff --git a/apps/studio/src/components/tests/site-content-tabs.test.tsx b/apps/studio/src/components/tests/site-content-tabs.test.tsx index 6f18fae43c..07770e4903 100644 --- a/apps/studio/src/components/tests/site-content-tabs.test.tsx +++ b/apps/studio/src/components/tests/site-content-tabs.test.tsx @@ -5,6 +5,7 @@ import { vi } from 'vitest'; import { SiteContentTabs } from 'src/components/site-content-tabs'; import { ContentTabsProvider } from 'src/hooks/use-content-tabs'; import { useSiteDetails } from 'src/hooks/use-site-details'; +import { getIpcApi } from 'src/lib/get-ipc-api'; import { WorkspaceSelectionProvider } from 'src/modules/workspaces'; import { store } from 'src/stores'; import { syncOperationsActions } from 'src/stores/sync'; @@ -19,6 +20,8 @@ const featureFlagsMock = vi.hoisted( () => ( { enableWorkspaces: false, } ) ); const useGetWpComSitesQueryMock = vi.hoisted( () => vi.fn() ); +const useGetActiveWpcomThemeQueryMock = vi.hoisted( () => vi.fn() ); +const useGetWpcomSiteSettingsQueryMock = vi.hoisted( () => vi.fn() ); const syncHooksMock = vi.hoisted( () => ( { useLatestRewindId: vi.fn(), useRemoteFileTree: vi.fn(), @@ -96,6 +99,7 @@ vi.mock( 'src/lib/get-ipc-api', async () => ( { showMessageBox: vi.fn().mockResolvedValue( { response: 0 } ), showNotification: vi.fn(), connectWpcomSites: vi.fn().mockResolvedValue( undefined ), + openSiteURL: vi.fn(), openURL: vi.fn(), setWindowControlVisibility: vi.fn(), } ), @@ -134,6 +138,8 @@ vi.mock( 'src/stores/sync/wpcom-sites', async () => { return { ...actual, useGetWpComSitesQuery: useGetWpComSitesQueryMock, + useGetActiveWpcomThemeQuery: useGetActiveWpcomThemeQueryMock, + useGetWpcomSiteSettingsQuery: useGetWpcomSiteSettingsQueryMock, }; } ); vi.mock( 'src/stores/sync/sync-hooks', async () => { @@ -192,12 +198,53 @@ const mockWpcomSitesQuery = ( sites: SyncSite[] = [] ) => { } ); }; +const mockActiveWpcomThemeQuery = ( + data: + | { + id?: string; + name?: string; + screenshotUrl?: string; + isBlockTheme?: boolean; + } + | undefined = undefined +) => { + useGetActiveWpcomThemeQueryMock.mockReturnValue( { + data, + isLoading: false, + isFetching: false, + refetch: vi.fn(), + } ); +}; + +const mockWpcomSiteSettingsQuery = ( + data: + | { + id?: number; + name?: string; + description?: string; + url?: string; + lang?: string; + localeVariant?: string; + settings: Record< string, unknown >; + } + | undefined = undefined +) => { + useGetWpcomSiteSettingsQueryMock.mockReturnValue( { + data, + isLoading: false, + isFetching: false, + refetch: vi.fn(), + } ); +}; + describe( 'SiteContentTabs', () => { beforeEach( () => { vi.clearAllMocks(); // Clear mock call history between tests localStorage.clear(); featureFlagsMock.enableWorkspaces = false; mockWpcomSitesQuery(); + mockActiveWpcomThemeQuery(); + mockWpcomSiteSettingsQuery(); syncHooksMock.useLatestRewindId.mockReturnValue( { rewindId: null, isLoading: false, @@ -260,14 +307,25 @@ describe( 'SiteContentTabs', () => { expect( screen.getByRole( 'tab', { name: 'Overview' } ) ).toBeVisible(); expect( screen.getByRole( 'tab', { name: 'Previews' } ) ).toBeVisible(); expect( screen.getByRole( 'tab', { name: 'Import / Export' } ) ).toBeVisible(); + expect( screen.getByRole( 'tab', { name: 'Assistant' } ) ).toBeVisible(); expect( - within( screen.getByTestId( 'workspace-content-body' ) ).getByLabelText( + screen + .getByTestId( 'workspace-content-body' ) + .querySelector( '.workspace-content-shell__tabs--preview-controls-closed' ) + ).toHaveStyle( '--workspace-preview-controls-width: 520px' ); + expect( + within( screen.getByTestId( 'workspace-preview-controls' ) ).getByRole( 'button', { + name: 'Show preview', + } ) + ).toBeVisible(); + expect( + within( screen.getByTestId( 'workspace-content-body' ) ).queryByLabelText( 'Workspace site preview' ) - ).toBeVisible(); + ).not.toBeInTheDocument(); } ); - it( 'renders local preview by default without starting the local site', async () => { + it( 'keeps local preview closed by default without starting the local site', async () => { const startServer = vi.fn( () => Promise.resolve() ); featureFlagsMock.enableWorkspaces = true; vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { @@ -278,14 +336,11 @@ describe( 'SiteContentTabs', () => { expect( startServer ).not.toHaveBeenCalled(); expect( - within( screen.getByTestId( 'workspace-content-body' ) ).getByLabelText( + within( screen.getByTestId( 'workspace-content-body' ) ).queryByLabelText( 'Workspace site preview' ) - ).toBeVisible(); - expect( screen.getByTitle( 'Test Site preview' ) ).toHaveAttribute( - 'src', - 'http://localhost:8881/' - ); + ).not.toBeInTheDocument(); + expect( screen.getByRole( 'button', { name: 'Show preview' } ) ).toBeVisible(); } ); it( 'keeps the local workspace Assistant tab on the existing local assistant', async () => { @@ -326,7 +381,11 @@ describe( 'SiteContentTabs', () => { expect( screen.getByRole( 'tab', { name: 'Previews' } ) ).toBeVisible(); expect( screen.getByRole( 'tab', { name: 'Import / Export' } ) ).toBeVisible(); - await user.click( screen.getByRole( 'button', { name: 'Preview target' } ) ); + await user.click( + within( screen.getByTestId( 'workspace-content-header' ) ).getByRole( 'button', { + name: 'Workspace target', + } ) + ); await user.click( screen.getByRole( 'option', { name: 'Production' } ) ); expect( screen.getByRole( 'tab', { name: 'Overview', selected: true } ) ).toBeVisible(); @@ -334,14 +393,21 @@ describe( 'SiteContentTabs', () => { expect( screen.getByRole( 'tab', { name: 'Import / Export' } ) ).toBeVisible(); expect( screen.getByRole( 'tab', { name: 'Sync' } ) ).toBeVisible(); expect( screen.getByRole( 'tab', { name: 'Settings' } ) ).toBeVisible(); - expect( screen.getByText( 'This section is managed in the Local target.' ) ).toBeVisible(); + expect( screen.getByRole( 'button', { name: 'Site Editor' } ) ).toBeVisible(); + expect( + screen.queryByText( 'This section is managed in the Local target.' ) + ).not.toBeInTheDocument(); await user.click( screen.getByRole( 'tab', { name: 'Assistant' } ) ); expect( screen.getByTestId( 'workspace-dolly-assistant' ) ).toBeInTheDocument(); expect( screen.queryByTestId( 'local-content-tab-assistant' ) ).not.toBeInTheDocument(); - await user.click( screen.getByRole( 'button', { name: 'Preview target' } ) ); + await user.click( + within( screen.getByTestId( 'workspace-content-header' ) ).getByRole( 'button', { + name: 'Workspace target', + } ) + ); await user.click( screen.getByRole( 'option', { name: 'Local' } ) ); expect( screen.getByRole( 'tab', { name: 'Assistant', selected: true } ) ).toBeVisible(); @@ -352,8 +418,10 @@ describe( 'SiteContentTabs', () => { expect( screen.queryByTestId( 'workspace-dolly-assistant' ) ).not.toBeInTheDocument(); } ); - it( 'renders remote Production targets with remote tabs only', async () => { + it( 'renders remote Production targets with the shared workspace tabs', async () => { + const user = userEvent.setup(); featureFlagsMock.enableWorkspaces = true; + mockActiveWpcomThemeQuery( { name: 'Remote Theme', isBlockTheme: true } ); mockWpcomSitesQuery( [ createSyncSite( { id: 101, name: 'Remote Only' } ) ] ); vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { ...createSiteDetailsReturn( { selectedSite: null, sites: [] } ), @@ -362,27 +430,82 @@ describe( 'SiteContentTabs', () => { await act( async () => renderWithProvider( ) ); expect( screen.getByTestId( 'workspace-content-header' ) ).toBeInTheDocument(); - expect( screen.getByRole( 'tab', { name: 'Assistant', selected: true } ) ).toBeVisible(); - expect( screen.getByRole( 'tab', { name: 'Assistant', selected: true } ) ).toHaveClass( + expect( screen.getByRole( 'tab', { name: 'Overview', selected: true } ) ).toBeVisible(); + expect( screen.getByRole( 'tab', { name: 'Assistant' } ) ).toBeVisible(); + expect( screen.getByRole( 'tab', { name: 'Assistant' } ) ).toHaveClass( 'components-tab-panel__tabs--assistant' ); - expect( screen.getByRole( 'tab', { name: 'Assistant', selected: true } ) ).not.toHaveClass( - 'ltr:ml-auto' - ); + expect( screen.getByRole( 'tab', { name: 'Assistant' } ) ).toHaveClass( 'ltr:ml-auto' ); expect( screen.getByRole( 'tab', { name: 'Sync' } ) ).toBeVisible(); + expect( screen.getByRole( 'tab', { name: 'Previews' } ) ).toBeVisible(); + expect( screen.getByRole( 'tab', { name: 'Import / Export' } ) ).toBeVisible(); expect( screen.getByRole( 'tab', { name: 'Settings' } ) ).toBeVisible(); - expect( screen.queryByRole( 'tab', { name: 'Overview' } ) ).not.toBeInTheDocument(); - expect( screen.queryByRole( 'tab', { name: 'Previews' } ) ).not.toBeInTheDocument(); + expect( screen.getByText( 'Remote Theme' ) ).toBeVisible(); + expect( screen.getByRole( 'button', { name: 'Site Editor' } ) ).toBeVisible(); + expect( screen.queryByText( 'Open in…' ) ).not.toBeInTheDocument(); + + await user.click( screen.getByRole( 'button', { name: 'Site Editor' } ) ); + + expect( getIpcApi().openURL ).toHaveBeenLastCalledWith( + 'https://remote.example/wp-admin/site-editor.php' + ); expect( - within( screen.getByTestId( 'workspace-content-body' ) ).getByLabelText( + within( screen.getByTestId( 'workspace-content-body' ) ).queryByLabelText( 'Workspace site preview' ) - ).toBeVisible(); + ).not.toBeInTheDocument(); expect( - within( screen.getByTestId( 'workspace-content-header' ) ).queryByRole( 'button', { + within( screen.getByTestId( 'workspace-preview-controls' ) ).getByRole( 'button', { name: 'Show preview', } ) - ).not.toBeInTheDocument(); + ).toBeVisible(); + } ); + + it( 'renders live site settings for the selected remote target', async () => { + const user = userEvent.setup(); + featureFlagsMock.enableWorkspaces = true; + mockWpcomSitesQuery( [ + createSyncSite( { + id: 101, + name: 'Remote Only', + url: 'https://remote.example', + planName: 'Business', + wpVersion: '6.8', + canManageOptions: true, + } ), + ] ); + mockWpcomSiteSettingsQuery( { + id: 101, + name: 'Live Site Title', + description: 'Live site tagline', + url: 'https://remote.example', + lang: 'en', + settings: { + blog_public: 0, + show_on_front: 'page', + page_on_front: 42, + timezone_string: 'America/New_York', + jetpack_relatedposts_enabled: true, + }, + } ); + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn( { selectedSite: null, sites: [] } ), + } ); + + await act( async () => renderWithProvider( ) ); + await user.click( screen.getByRole( 'tab', { name: 'Settings' } ) ); + + expect( screen.getByText( 'Live Site Title' ) ).toBeVisible(); + expect( screen.getByText( 'Live site tagline' ) ).toBeVisible(); + expect( screen.getByText( 'Discourage search engines' ) ).toBeVisible(); + expect( screen.getByText( 'Static page, page ID 42' ) ).toBeVisible(); + expect( screen.getByText( 'Business' ) ).toBeVisible(); + + await user.click( screen.getByRole( 'button', { name: 'Reading' } ) ); + + expect( getIpcApi().openURL ).toHaveBeenLastCalledWith( + 'https://remote.example/wp-admin/options-reading.php' + ); } ); it( 'opens remote preview under the workspace header', async () => { @@ -394,6 +517,16 @@ describe( 'SiteContentTabs', () => { } ); await act( async () => renderWithProvider( ) ); + await user.click( + within( screen.getByTestId( 'workspace-preview-controls' ) ).getByRole( 'button', { + name: 'Show preview', + } ) + ); + expect( + screen + .getByTestId( 'workspace-content-body' ) + .querySelector( '.workspace-content-shell__tabs--preview-controls-closed' ) + ).not.toBeInTheDocument(); expect( within( screen.getByTestId( 'workspace-preview-controls' ) ).getByRole( 'button', { @@ -430,6 +563,7 @@ describe( 'SiteContentTabs', () => { } ); it( 'releases preview resizing even when dragging over the preview panel', async () => { + const user = userEvent.setup(); featureFlagsMock.enableWorkspaces = true; mockWpcomSitesQuery( [ createSyncSite( { id: 101, name: 'Remote Only' } ) ] ); vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { @@ -437,6 +571,11 @@ describe( 'SiteContentTabs', () => { } ); await act( async () => renderWithProvider( ) ); + await user.click( + within( screen.getByTestId( 'workspace-preview-controls' ) ).getByRole( 'button', { + name: 'Show preview', + } ) + ); const previewPanel = within( screen.getByTestId( 'workspace-content-body' ) ).getByLabelText( 'Workspace site preview' @@ -497,7 +636,25 @@ describe( 'SiteContentTabs', () => { } ); await act( async () => renderWithProvider( ) ); - await user.click( screen.getByRole( 'button', { name: 'Preview target' } ) ); + await user.click( + within( screen.getByTestId( 'workspace-preview-controls' ) ).getByRole( 'button', { + name: 'Show preview', + } ) + ); + const resizeHandle = within( screen.getByTestId( 'workspace-content-body' ) ).getByRole( + 'separator', + { + name: 'Resize preview', + } + ); + resizeHandle.focus(); + await user.keyboard( '{ArrowLeft}' ); + await user.click( screen.getByRole( 'tab', { name: 'Assistant' } ) ); + await user.click( + within( screen.getByTestId( 'workspace-content-header' ) ).getByRole( 'button', { + name: 'Workspace target', + } ) + ); await user.click( screen.getByRole( 'option', { name: 'Production' } ) ); expect( screen.getByRole( 'tab', { name: 'Assistant', selected: true } ) ).toBeVisible(); @@ -505,6 +662,7 @@ describe( 'SiteContentTabs', () => { 'src', 'https://production.example/' ); + expect( resizeHandle ).toHaveAttribute( 'aria-valuenow', '552' ); } ); it( 'rebases chat-updated preview URLs when switching targets', async () => { @@ -529,6 +687,7 @@ describe( 'SiteContentTabs', () => { } ); await act( async () => renderWithProvider( ) ); + await user.click( screen.getByRole( 'tab', { name: 'Assistant' } ) ); await user.click( screen.getByRole( 'button', { name: 'Mock open staging preview' } ) ); await waitFor( () => @@ -538,7 +697,11 @@ describe( 'SiteContentTabs', () => { ) ); - await user.click( screen.getByRole( 'button', { name: 'Preview target' } ) ); + await user.click( + within( screen.getByTestId( 'workspace-content-header' ) ).getByRole( 'button', { + name: 'Workspace target', + } ) + ); await user.click( screen.getByRole( 'option', { name: 'Production' } ) ); await waitFor( () => @@ -549,7 +712,8 @@ describe( 'SiteContentTabs', () => { ); } ); - it( 'renders the preview target menu from the browser bar only', async () => { + it( 'renders a visible header target picker backed by the preview target', async () => { + const user = userEvent.setup(); featureFlagsMock.enableWorkspaces = true; mockWpcomSitesQuery( [ createSyncSite( { @@ -568,17 +732,75 @@ describe( 'SiteContentTabs', () => { await act( async () => renderWithProvider( ) ); + const headerTargetPicker = within( screen.getByTestId( 'workspace-content-header' ) ).getByRole( + 'button', + { name: 'Workspace target' } + ); expect( screen.queryByRole( 'button', { name: /Select Local target:/ } ) ).not.toBeInTheDocument(); expect( screen.queryByRole( 'button', { name: /Select Production target:/ } ) ).not.toBeInTheDocument(); - expect( screen.queryByRole( 'combobox', { name: 'Preview target' } ) ).not.toBeInTheDocument(); - expect( screen.getByRole( 'button', { name: 'Preview target' } ) ).toBeVisible(); + expect( screen.queryByRole( 'button', { name: 'Preview target' } ) ).not.toBeInTheDocument(); + expect( headerTargetPicker ).toHaveTextContent( /Viewing\s*Local/ ); expect( screen.queryByLabelText( 'Local target: Test Site is stopped' ) ).not.toBeInTheDocument(); + + await user.click( headerTargetPicker ); + await user.click( screen.getByRole( 'option', { name: 'Production' } ) ); + + expect( headerTargetPicker ).toHaveTextContent( /Viewing\s*Production/ ); + await user.click( screen.getByRole( 'button', { name: 'Show preview' } ) ); + expect( screen.getByTitle( 'Linked Workspace preview' ) ).toHaveAttribute( + 'src', + 'https://linked.example/' + ); + } ); + + it( 'updates workspace header links for the selected live target', async () => { + const user = userEvent.setup(); + featureFlagsMock.enableWorkspaces = true; + const productionSite = createSyncSite( { + id: 101, + name: 'Remote Workspace', + url: 'https://production.example', + stagingSiteIds: [ 202 ], + } ); + const stagingSite = createSyncSite( { + id: 202, + name: 'Remote Workspace Staging', + url: 'https://staging.example', + isStaging: true, + productionSiteId: 101, + } ); + mockWpcomSitesQuery( [ productionSite, stagingSite ] ); + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn( { selectedSite: null, sites: [] } ), + } ); + + await act( async () => renderWithProvider( ) ); + + const header = within( screen.getByTestId( 'workspace-content-header' ) ); + expect( header.getByRole( 'button', { name: /Staging WP admin/ } ) ).toBeVisible(); + expect( header.getByRole( 'button', { name: /Open staging site/ } ) ).toBeVisible(); + + await user.click( header.getByRole( 'button', { name: /Open staging site/ } ) ); + + expect( getIpcApi().openURL ).toHaveBeenLastCalledWith( 'https://staging.example/' ); + + await user.click( header.getByRole( 'button', { name: 'Workspace target' } ) ); + await user.click( screen.getByRole( 'option', { name: 'Production' } ) ); + + expect( header.getByRole( 'button', { name: /Production WP admin/ } ) ).toBeVisible(); + expect( header.getByRole( 'button', { name: /Open production site/ } ) ).toBeVisible(); + + await user.click( header.getByRole( 'button', { name: /Production WP admin/ } ) ); + + expect( getIpcApi().openURL ).toHaveBeenLastCalledWith( + 'https://production.example/wp-admin/' + ); } ); it( 'shows the local Start button only when the Local preview target is selected', async () => { @@ -615,7 +837,11 @@ describe( 'SiteContentTabs', () => { expect( startServer ).toHaveBeenCalledWith( selectedSite ); - await user.click( screen.getByRole( 'button', { name: 'Preview target' } ) ); + await user.click( + within( screen.getByTestId( 'workspace-content-header' ) ).getByRole( 'button', { + name: 'Workspace target', + } ) + ); await user.click( screen.getByRole( 'option', { name: 'Production' } ) ); expect( @@ -644,11 +870,14 @@ describe( 'SiteContentTabs', () => { } ); await act( async () => renderWithProvider( ) ); - await user.click( screen.getByRole( 'button', { name: 'Close preview' } ) ); expect( screen.queryByLabelText( 'Workspace site preview' ) ).not.toBeInTheDocument(); - await user.click( screen.getByRole( 'button', { name: 'Preview target' } ) ); + await user.click( + within( screen.getByTestId( 'workspace-content-header' ) ).getByRole( 'button', { + name: 'Workspace target', + } ) + ); await user.click( screen.getByRole( 'option', { name: 'Production' } ) ); expect( screen.queryByLabelText( 'Workspace site preview' ) ).not.toBeInTheDocument(); @@ -712,9 +941,9 @@ describe( 'SiteContentTabs', () => { await user.click( screen.getByRole( 'tab', { name: 'Sync' } ) ); expect( screen.getByTestId( 'workspace-sync-panel' ) ).toBeVisible(); - expect( screen.getByText( 'Local <-> Production' ) ).toBeVisible(); - expect( screen.getByText( 'Local <-> Staging' ) ).toBeVisible(); - expect( screen.getByText( 'Production <-> Staging' ) ).toBeVisible(); + expect( screen.getAllByText( 'Linked Workspace' ).length ).toBeGreaterThan( 0 ); + expect( screen.getByText( 'Linked Workspace Staging' ) ).toBeVisible(); + expect( screen.getByText( 'Production and staging' ) ).toBeVisible(); expect( screen.getByRole( 'button', { name: 'Push to Staging' } ) ).toBeEnabled(); expect( screen.getByRole( 'button', { name: 'Pull to Production' } ) ).toBeEnabled(); @@ -753,9 +982,9 @@ describe( 'SiteContentTabs', () => { expect( screen.queryByText( 'No workspace sync links are available yet.' ) ).not.toBeInTheDocument(); - expect( screen.getByText( 'Local <-> Production' ) ).toBeVisible(); + expect( screen.getAllByText( 'Remote Workspace' ).length ).toBeGreaterThan( 0 ); expect( screen.getByRole( 'button', { name: 'Create local copy' } ) ).toBeVisible(); - expect( screen.getByText( 'Production <-> Staging' ) ).toBeVisible(); + expect( screen.getByText( 'Production and staging' ) ).toBeVisible(); expect( screen.getByRole( 'button', { name: 'Create staging site' } ) ).toBeVisible(); } ); @@ -844,12 +1073,12 @@ describe( 'SiteContentTabs', () => { await act( async () => renderWithProvider( ) ); await user.click( screen.getByRole( 'tab', { name: 'Sync' } ) ); - expect( screen.getByText( 'Local <-> Production' ) ).toBeVisible(); + expect( screen.getAllByText( 'Linked Workspace' ).length ).toBeGreaterThan( 0 ); expect( - screen.getByText( 'Connect this target to the local site before syncing.' ) + screen.getByText( 'Connect this site to the local site before syncing.' ) ).toBeVisible(); expect( screen.getByRole( 'button', { name: 'Connect' } ) ).toBeEnabled(); - expect( screen.getByText( 'Local <-> Staging' ) ).toBeVisible(); + expect( screen.getByText( 'Linked Workspace Staging' ) ).toBeVisible(); } ); it( 'lets Production/Staging sync select specific source files', async () => { diff --git a/apps/studio/src/index.css b/apps/studio/src/index.css index fdf277c36c..bbe7023f5a 100644 --- a/apps/studio/src/index.css +++ b/apps/studio/src/index.css @@ -130,6 +130,10 @@ blockquote { border-bottom: 1px solid var( --color-frame-border ); } +.workspace-content-shell__tabs--preview-controls-closed .components-tab-panel__tabs { + padding-inline-end: calc( var( --workspace-preview-controls-width, 0px ) + 1rem ); +} + .components-tab-panel__tabs-item { height: 40px; margin-bottom: -1px; 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 6b38a93dbf..af4a7e18e5 100644 --- a/apps/studio/src/modules/sync/components/sync-connected-sites.tsx +++ b/apps/studio/src/modules/sync/components/sync-connected-sites.tsx @@ -3,7 +3,7 @@ import { createInterpolateElement } from '@wordpress/element'; import { sprintf } from '@wordpress/i18n'; import { cloudUpload, cloudDownload, info, close, error } from '@wordpress/icons'; import { useI18n } from '@wordpress/react-i18n'; -import { useState } from 'react'; +import { useState, type ReactNode } from 'react'; import { ArrowIcon } from 'src/components/arrow-icon'; import Button from 'src/components/button'; import { ClearAction } from 'src/components/clear-action'; @@ -775,13 +775,17 @@ export function SyncConnectedSites( { connectedSites, disconnectSite, selectedSite, + children, + className, }: { connectedSites: SyncSite[]; disconnectSite: ( id: number ) => void; selectedSite: SiteDetails; + children?: ReactNode; + className?: string; } ) { return ( -
    +
    { connectedSites.map( ( connectedSite ) => ( ) ) } + { children }
    ); } diff --git a/apps/studio/src/modules/sync/components/workspace-sync-panel.tsx b/apps/studio/src/modules/sync/components/workspace-sync-panel.tsx index a242430540..025f9d5947 100644 --- a/apps/studio/src/modules/sync/components/workspace-sync-panel.tsx +++ b/apps/studio/src/modules/sync/components/workspace-sync-panel.tsx @@ -6,17 +6,20 @@ import { ArrowIcon } from 'src/components/arrow-icon'; import Button from 'src/components/button'; import { Tooltip } from 'src/components/tooltip'; import { TreeView, updateNodeById, type TreeNode } from 'src/components/tree-view'; +import { WordPressLogoCircle } from 'src/components/wordpress-logo-circle'; import { useAddSite } from 'src/hooks/use-add-site'; import { cx } from 'src/lib/cx'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { getLocalizedLink } from 'src/lib/get-localized-link'; -import { SyncConnectedSiteControls } from 'src/modules/sync/components/sync-connected-sites'; +import { EnvironmentBadge } from 'src/modules/sync/components/environment-badge'; +import { SyncConnectedSites } from 'src/modules/sync/components/sync-connected-sites'; import { SyncFilesSelectControl, type SyncFilesSelectionMode, } from 'src/modules/sync/components/sync-files-select-control'; import { SyncModalShell } from 'src/modules/sync/components/sync-modal-shell'; import { TreeViewLoadingSkeleton } from 'src/modules/sync/components/tree-view-loading-skeleton'; +import { getSiteEnvironment } from 'src/modules/sync/lib/environment-utils'; import { useWorkspaceSelection } from 'src/modules/workspaces'; import { useAppDispatch, useI18nLocale, useRootSelector } from 'src/stores'; import { @@ -27,7 +30,7 @@ import { type StagingSyncOption, type StagingSyncOptions, } from 'src/stores/sync'; -import { useConnectSiteMutation } from 'src/stores/sync/connected-sites'; +import { useConnectSiteMutation, useDisconnectSiteMutation } from 'src/stores/sync/connected-sites'; import { useLatestRewindId, useRemoteFileTree } from 'src/stores/sync/sync-hooks'; import type { SyncSite } from '@studio/common/types/sync'; import type { StudioWorkspace, WorkspaceTargetId } from 'src/modules/workspaces/types'; @@ -171,7 +174,7 @@ function getProductionStagingSetupState( productionSite: SyncSite ) { return { canCreateStagingSite: true, - description: __( 'Create a staging target from Production before syncing environments.' ), + description: __( 'Create a live staging site from Production.' ), buttonLabel: __( 'Create staging site' ), }; } @@ -491,35 +494,38 @@ function EnvironmentSyncDialog( { ); } -function WorkspaceSyncRow( { +function WorkspaceSyncSection( { label, description, + badges, active, children, }: { label: string; - description: string; + description: ReactNode; + badges?: ReactNode; active?: boolean; children: ReactNode; } ) { return (
    -
    -
    { label }
    -
    { description }
    +
    + + { badges } +
    { label }
    +
    { children }
    -
    { children }
    +
    { description }
    ); } -function LocalRemoteSyncRow( { - label, +function WorkspaceSetupSyncSection( { localSite, remoteSite, remoteTargetId, @@ -530,7 +536,6 @@ function LocalRemoteSyncRow( { isCreatingLocalSite, isConnectingSite, }: { - label: string; localSite?: SiteDetails; remoteSite?: SyncSite; remoteTargetId: Extract< WorkspaceTargetId, 'production' | 'staging' >; @@ -541,7 +546,6 @@ function LocalRemoteSyncRow( { isCreatingLocalSite?: boolean; isConnectingSite?: boolean; } ) { - const isConnected = isRemoteConnectedToLocal( remoteSite, localSite ); const missingDescription = remoteTargetId === 'production' ? __( 'Connect or create a Production target before syncing this link.' ) @@ -551,27 +555,27 @@ function LocalRemoteSyncRow( { const canUseLocalSetup = localSetupState?.canUseLocalSetup; const canConnectRemoteSite = Boolean( localSite && remoteSite && canUseLocalSetup ); const description = - isConnected && remoteSite - ? remoteSite.url - : remoteSite && ! canUseLocalSetup && localSetupState + remoteSite && ! canUseLocalSetup && localSetupState ? localSetupState.description ?? missingDescription : remoteSite && ! localSite - ? __( 'Create a local copy of this target before syncing.' ) + ? __( 'Create a local copy of this site to vibe with.' ) : remoteSite && localSite - ? __( 'Connect this target to the local site before syncing.' ) + ? __( 'Connect this site to the local site before syncing.' ) : missingDescription; + const label = + remoteSite?.name ?? + ( remoteTargetId === 'production' ? __( 'Production target' ) : __( 'Staging target' ) ); + const environmentType = remoteSite ? getSiteEnvironment( remoteSite ) : remoteTargetId; return ( - + } + active={ active } + >
    - { isConnected && localSite && remoteSite ? ( - - ) : remoteSite && ! localSite && canUseLocalSetup ? ( + { remoteSite && ! localSite && canUseLocalSetup ? (
    -
    + ); } @@ -663,11 +667,19 @@ function EnvironmentSyncRow( { : stagingSyncState?.status === 'completed' ? __( 'Last environment sync completed.' ) : __( 'Copy content between production and staging.' ); + const label = __( 'Production and staging' ); + const badges = ( + <> + + { stagingSite && } + + ); return ( - Staging' ) } + @@ -716,7 +728,7 @@ function EnvironmentSyncRow( { ) }
    - + ); } @@ -728,6 +740,7 @@ export function WorkspaceSyncPanelContent( { const { createSiteFromRemoteSite } = useAddSite(); const { refreshWorkspaces } = useWorkspaceSelection(); const [ connectSite ] = useConnectSiteMutation(); + const [ disconnectSite ] = useDisconnectSiteMutation(); const [ environmentSyncDirection, setEnvironmentSyncDirection ] = useState< StagingSyncDirection | null >( null ); const [ creatingLocalSiteId, setCreatingLocalSiteId ] = useState< number | null >( null ); @@ -740,13 +753,26 @@ export function WorkspaceSyncPanelContent( { const stagingSite = workspace.targets.staging?.site; const localProductionSyncState = useLocalRemoteSyncState( localSite, productionSite ); const localStagingSyncState = useLocalRemoteSyncState( localSite, stagingSite ); + const isProductionConnectedToLocal = isRemoteConnectedToLocal( productionSite, localSite ); + const isStagingConnectedToLocal = isRemoteConnectedToLocal( stagingSite, localSite ); + const connectedLocalSites = useMemo( + () => + [ productionSite, stagingSite ].filter( + ( site ): site is SyncSite => Boolean( site ) && isRemoteConnectedToLocal( site, localSite ) + ), + [ localSite, productionSite, stagingSite ] + ); const isEnvironmentSyncing = useRootSelector( stagingSyncSelectors.selectIsProductionSiteSyncing( productionSite?.id ) ); const isAnyLocalRemoteSyncing = localProductionSyncState.isSyncing || localStagingSyncState.isSyncing; - const shouldShowLocalProductionRow = Boolean( localSite || productionSite ); - const shouldShowLocalStagingRow = Boolean( stagingSite && ( localSite || ! productionSite ) ); + const shouldShowLocalProductionSetupRow = Boolean( + ( productionSite && ! isProductionConnectedToLocal ) || ( localSite && ! productionSite ) + ); + const shouldShowLocalStagingSetupRow = Boolean( + stagingSite && ! isStagingConnectedToLocal && ( localSite || ! productionSite ) + ); const shouldShowEnvironmentRow = Boolean( productionSite || stagingSite ); const handleCreateLocalSite = useCallback( @@ -794,6 +820,28 @@ export function WorkspaceSyncPanelContent( { [ connectSite, refreshWorkspaces ] ); + const handleDisconnectSite = useCallback( + async ( siteId: number ) => { + if ( ! localSite ) { + return; + } + + try { + await disconnectSite( { siteId, localSiteId: localSite.id } ); + refreshWorkspaces(); + } catch ( error ) { + getIpcApi().showErrorMessageBox( { + title: __( 'Could not disconnect site' ), + message: + error instanceof Error + ? error.message + : __( 'The WordPress.com site could not be disconnected.' ), + } ); + } + }, + [ disconnectSite, localSite, refreshWorkspaces ] + ); + const handleCreateStagingSite = useCallback( async ( site: SyncSite ) => { setCreatingStagingProductionSiteId( site.id ); @@ -846,8 +894,9 @@ export function WorkspaceSyncPanelContent( { }, [ dispatch, isEnvironmentSyncing, productionSite?.id, stagingSite?.id ] ); if ( - ! shouldShowLocalProductionRow && - ! shouldShowLocalStagingRow && + connectedLocalSites.length === 0 && + ! shouldShowLocalProductionSetupRow && + ! shouldShowLocalStagingSetupRow && ! shouldShowEnvironmentRow ) { return ( @@ -859,52 +908,62 @@ export function WorkspaceSyncPanelContent( { ); } + const syncSections = ( + <> + { shouldShowLocalProductionSetupRow && ( + + ) } + { shouldShowLocalStagingSetupRow && ( + + ) } + { shouldShowEnvironmentRow && ( + + ) } + + ); + return ( -
    -
    -

    { __( 'Sync' ) }

    -
    - { shouldShowLocalProductionRow && ( - Production' ) } - localSite={ localSite } - remoteSite={ productionSite } - remoteTargetId="production" - disabled={ isEnvironmentSyncing } - active={ selectedTargetId === 'production' } - onCreateLocalSite={ handleCreateLocalSite } - onConnectRemoteSite={ handleConnectRemoteSite } - isCreatingLocalSite={ creatingLocalSiteId === productionSite?.id } - isConnectingSite={ connectingRemoteSiteId === productionSite?.id } - /> - ) } - { shouldShowLocalStagingRow && ( - Staging' ) } - localSite={ localSite } - remoteSite={ stagingSite } - remoteTargetId="staging" - disabled={ isEnvironmentSyncing } - active={ selectedTargetId === 'staging' } - onCreateLocalSite={ handleCreateLocalSite } - onConnectRemoteSite={ handleConnectRemoteSite } - isCreatingLocalSite={ creatingLocalSiteId === stagingSite?.id } - isConnectingSite={ connectingRemoteSiteId === stagingSite?.id } - /> - ) } - { shouldShowEnvironmentRow && ( - - ) } -
    -
    +
    + { localSite ? ( + + { syncSections } + + ) : ( +
    { syncSections }
    + ) } { environmentSyncDirection && productionSite && stagingSite && ( [ 'tabs' ], - workspace: StudioWorkspace -) { - const tabsByName = new Map( tabs.map( ( tab ) => [ tab.name, tab ] ) ); - return getWorkspaceTabIds( workspace ) - .map( ( tabId ) => tabsByName.get( tabId ) ) - .filter( ( tab ): tab is NonNullable< typeof tab > => Boolean( tab ) ) - .map( ( tab ) => ( { - ...tab, - className: tab.className?.replace( /\s*ltr:ml-auto\s+rtl:mr-auto\s*/g, ' ' ).trim(), - } ) ); -} - function resolveLocalPreviewBaseUrl( site: SiteDetails ) { if ( site.running ) { return site.url; @@ -193,6 +179,15 @@ function getTransportTarget( workspace: StudioWorkspace ) { return workspace.targets.staging ?? workspace.targets.production; } +function getSelectedRemoteTarget( workspace: StudioWorkspace, targetId?: WorkspaceTargetId ) { + if ( targetId === 'production' ) { + return workspace.targets.production; + } + if ( targetId === 'staging' ) { + return workspace.targets.staging; + } +} + export function WorkspaceContentShell() { const { tabs } = useContentTabs(); const { importState } = useImportExport(); @@ -263,10 +258,7 @@ export function WorkspaceContentShell() { const previewTarget = previewTargets.find( ( target ) => target.id === selectedPreviewTargetId ) ?? previewTargets[ 0 ]; - const workspaceTabs = useMemo( - () => ( selectedWorkspace ? getOrderedWorkspaceTabs( tabs, selectedWorkspace ) : [] ), - [ selectedWorkspace, tabs ] - ); + const workspaceTabs = tabs; if ( ! selectedWorkspace ) { return ; @@ -287,7 +279,7 @@ export function WorkspaceContentShell() { } } - const activeTabId = selectedTabId ?? getDefaultWorkspaceTabId( selectedWorkspace ); + const activeTabId = selectedTabId ?? 'overview'; const targetPreviewState = previewTarget ? getPreviewStateForTarget( previewState, previewTarget ) : previewState; @@ -296,6 +288,11 @@ export function WorkspaceContentShell() { : undefined; const previewUrl = targetPreviewState.currentUrl ?? resolvedPreviewUrl; const localContextSite = previewTarget?.id === 'local' ? localTarget?.site : undefined; + const selectedRemoteTarget = getSelectedRemoteTarget( selectedWorkspace, previewTarget?.id ); + const previewControlsAreClosed = Boolean( previewTarget && ! targetPreviewState.open ); + const previewControlsStyle = { + '--workspace-preview-controls-width': `${ targetPreviewState.width }px`, + } as CSSProperties; const updatePreviewState = ( nextPreviewState: WorkspacePreviewState ) => { if ( ! previewKey ) { @@ -403,6 +400,9 @@ export function WorkspaceContentShell() { workspace={ selectedWorkspace } showLocalManagementActions={ previewTarget?.id === 'local' } onStartLocalSite={ startLocalSiteFromHeader } + previewTargets={ previewTargets } + selectedPreviewTargetId={ previewTarget?.id } + onSelectPreviewTarget={ selectPreviewTarget } />
    ) } -
    +
    + ) : selectedRemoteTarget ? ( + ) : ( ) ) } @@ -485,6 +490,8 @@ export function WorkspaceContentShell() { { name === 'settings' && ( localContextSite ? ( + ) : selectedRemoteTarget ? ( + ) : ( ) ) } diff --git a/apps/studio/src/modules/workspaces/components/workspace-dolly-assistant.test.tsx b/apps/studio/src/modules/workspaces/components/workspace-dolly-assistant.test.tsx index 0dd039b96c..d71cb596aa 100644 --- a/apps/studio/src/modules/workspaces/components/workspace-dolly-assistant.test.tsx +++ b/apps/studio/src/modules/workspaces/components/workspace-dolly-assistant.test.tsx @@ -617,6 +617,17 @@ describe( 'WorkspaceDollyAssistant', () => { ); } ); + it( 'warns when Dolly is using the production target', () => { + renderDollyAssistant( { + transportTarget: productionTarget, + previewTargetId: 'production', + } ); + + expect( screen.getByRole( 'note' ) ).toHaveTextContent( + 'Production site: changes requested in this chat can be applied directly to the live site.' + ); + } ); + it( 'sends the selected local preview URL and local site id while keeping the remote transport session', async () => { const { requestBodies, requestUrls } = mockDollyFetch( () => createDollyResponse( 'Local context response', 'session-workspace', 'task-local' ) diff --git a/apps/studio/src/modules/workspaces/components/workspace-dolly-assistant.tsx b/apps/studio/src/modules/workspaces/components/workspace-dolly-assistant.tsx index 43776bfb96..a92a6189e5 100644 --- a/apps/studio/src/modules/workspaces/components/workspace-dolly-assistant.tsx +++ b/apps/studio/src/modules/workspaces/components/workspace-dolly-assistant.tsx @@ -1335,6 +1335,7 @@ export function WorkspaceDollyAssistant( { const isInputUnavailable = isOffline || ! isAuthenticated || ! client; const isInputDisabled = isInputUnavailable && ! isCurrentSessionAssistantThinking; const isInputActionDisabled = isInputUnavailable || isCurrentSessionAssistantThinking; + const isProductionAssistantContext = currentPreviewTarget.targetId === 'production'; const dollyInputActions = useMemo( () => [ { @@ -1412,6 +1413,16 @@ export function WorkspaceDollyAssistant( { showHeader={ false } className="relative min-h-0 overflow-hidden px-6 pb-4 pt-6" > + { isProductionAssistantContext && ( +
    + { __( + 'Production site: changes requested in this chat can be applied directly to the live site.' + ) } +
    + ) } { showJumpToLatest && (
    diff --git a/apps/studio/src/modules/workspaces/components/workspace-header.tsx b/apps/studio/src/modules/workspaces/components/workspace-header.tsx index b16b141bd6..6638f625b9 100644 --- a/apps/studio/src/modules/workspaces/components/workspace-header.tsx +++ b/apps/studio/src/modules/workspaces/components/workspace-header.tsx @@ -1,27 +1,62 @@ +import { sprintf } from '@wordpress/i18n'; import { useI18n } from '@wordpress/react-i18n'; import { ArrowIcon } from 'src/components/arrow-icon'; import Button from 'src/components/button'; import { SiteManagementActions } from 'src/components/site-management-actions'; import { useSiteDetails } from 'src/hooks/use-site-details'; import { getIpcApi } from 'src/lib/get-ipc-api'; -import type { StudioWorkspace } from 'src/modules/workspaces/types'; +import { + WorkspacePreviewTargetPicker, + type WorkspacePreviewTargetOption, +} from 'src/modules/workspaces/components/workspace-preview'; +import type { StudioWorkspace, WorkspaceTargetId } from 'src/modules/workspaces/types'; type WorkspaceHeaderProps = { workspace: StudioWorkspace; showLocalManagementActions?: boolean; onStartLocalSite?: ( site: SiteDetails ) => Promise< void >; + previewTargets?: WorkspacePreviewTargetOption[]; + selectedPreviewTargetId?: WorkspaceTargetId; + onSelectPreviewTarget?: ( targetId: WorkspaceTargetId ) => void; }; +type WorkspaceHeaderLink = { + id: string; + label: string; + onClick: () => void | Promise< void >; + disabled?: boolean; +}; + +function resolveRemoteSiteUrl( siteUrl: string, path = '' ) { + try { + return new URL( path, siteUrl ).toString(); + } catch { + return siteUrl; + } +} + export function WorkspaceHeader( { workspace, showLocalManagementActions = false, onStartLocalSite, + previewTargets = [], + selectedPreviewTargetId, + onSelectPreviewTarget, }: WorkspaceHeaderProps ) { const { __ } = useI18n(); const { startServer, stopServer, loadingServer } = useSiteDetails(); const localSite = workspace.targets.local?.site; const isLoading = localSite?.id ? loadingServer[ localSite.id ] : false; const displayTitle = workspace.name || localSite?.name || __( 'Untitled workspace' ); + const showHeaderTargetPicker = Boolean( + selectedPreviewTargetId && previewTargets.length && onSelectPreviewTarget + ); + const selectedRemoteTarget = + selectedPreviewTargetId === 'production' + ? workspace.targets.production + : selectedPreviewTargetId === 'staging' + ? workspace.targets.staging + : undefined; const handleLocalWpAdminClick = async () => { if ( ! localSite || isLoading ) { @@ -43,6 +78,51 @@ export function WorkspaceHeader( { getIpcApi().openSiteURL( localSite.id, '', { autoLogin: false } ); }; + const headerLinks: WorkspaceHeaderLink[] = []; + + if ( selectedPreviewTargetId === 'local' && localSite ) { + headerLinks.push( + { + id: 'local-admin', + label: __( 'Local WP admin' ), + onClick: handleLocalWpAdminClick, + disabled: isLoading, + }, + { + id: 'local-site', + label: __( 'Open local site' ), + onClick: handleOpenLocalSiteClick, + disabled: isLoading, + } + ); + } else if ( selectedRemoteTarget ) { + const targetLabel = + selectedRemoteTarget.id === 'production' ? __( 'Production' ) : __( 'Staging' ); + const openSiteLabel = + selectedRemoteTarget.id === 'production' + ? __( 'Open production site' ) + : __( 'Open staging site' ); + headerLinks.push( + { + id: `${ selectedRemoteTarget.id }-admin`, + label: sprintf( + /* translators: %s is an environment name, such as Production or Staging. */ + __( '%s WP admin' ), + targetLabel + ), + onClick: () => + getIpcApi().openURL( + resolveRemoteSiteUrl( selectedRemoteTarget.site.url, '/wp-admin/' ) + ), + }, + { + id: `${ selectedRemoteTarget.id }-site`, + label: openSiteLabel, + onClick: () => getIpcApi().openURL( resolveRemoteSiteUrl( selectedRemoteTarget.site.url ) ), + } + ); + } + return (

    { displayTitle }

    - { localSite && ( - <> - - - - ) } + { headerLinks.map( ( link ) => ( + + ) ) }
    - { localSite && showLocalManagementActions && ( - + { ( showHeaderTargetPicker || ( localSite && showLocalManagementActions ) ) && ( +
    + { showHeaderTargetPicker && selectedPreviewTargetId && onSelectPreviewTarget && ( + + ) } + { localSite && showLocalManagementActions && ( + + ) } +
    ) }
    ); diff --git a/apps/studio/src/modules/workspaces/components/workspace-live-site-panels.tsx b/apps/studio/src/modules/workspaces/components/workspace-live-site-panels.tsx new file mode 100644 index 0000000000..65cdd67127 --- /dev/null +++ b/apps/studio/src/modules/workspaces/components/workspace-live-site-panels.tsx @@ -0,0 +1,382 @@ +import { sprintf, __ } from '@wordpress/i18n'; +import { + desktop, + layout, + navigation, + page, + pencil, + styles, + symbolFilled, + widget, +} from '@wordpress/icons'; +import { useEffect, useState } from 'react'; +import { ArrowIcon } from 'src/components/arrow-icon'; +import { ButtonsSection, ButtonsSectionProps } from 'src/components/buttons-section'; +import { cx } from 'src/lib/cx'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import { + useGetActiveWpcomThemeQuery, + useGetWpcomSiteSettingsQuery, + type WpcomSiteSettings, +} from 'src/stores/sync/wpcom-sites'; +import type { RemoteTarget } from 'src/modules/workspaces/types'; + +function resolveLiveSiteUrl( siteUrl: string, path = '' ) { + try { + return new URL( path, siteUrl ).toString(); + } catch { + return siteUrl; + } +} + +function getMshotsUrl( siteUrl: string ) { + return `https://s0.wp.com/mshots/v1/${ encodeURIComponent( siteUrl ) }?w=600&h=800`; +} + +function getTargetLabel( target: RemoteTarget ) { + return target.id === 'production' ? __( 'Production' ) : __( 'Staging' ); +} + +function getSettingString( + settings: WpcomSiteSettings | undefined, + key: string +): string | undefined { + const value = settings?.settings[ key ]; + return typeof value === 'string' && value ? value : undefined; +} + +function getSettingNumber( + settings: WpcomSiteSettings | undefined, + key: string +): number | undefined { + const value = settings?.settings[ key ]; + if ( typeof value === 'number' ) { + return value; + } + if ( typeof value === 'string' && value.trim() !== '' ) { + const parsed = Number( value ); + return Number.isFinite( parsed ) ? parsed : undefined; + } + return undefined; +} + +function getBooleanLikeLabel( value: unknown ) { + if ( value === true || value === 1 || value === '1' ) { + return __( 'Enabled' ); + } + if ( value === false || value === 0 || value === '0' ) { + return __( 'Disabled' ); + } + return undefined; +} + +function formatVisibility( settings: WpcomSiteSettings | undefined ) { + const visibility = settings?.settings.blog_public; + if ( visibility === -1 || visibility === '-1' ) { + return __( 'Private' ); + } + if ( visibility === 0 || visibility === '0' ) { + return __( 'Discourage search engines' ); + } + if ( visibility === 1 || visibility === '1' ) { + return __( 'Public' ); + } + return undefined; +} + +function formatHomepage( settings: WpcomSiteSettings | undefined ) { + const showOnFront = getSettingString( settings, 'show_on_front' ); + if ( showOnFront === 'page' ) { + const pageOnFront = getSettingNumber( settings, 'page_on_front' ); + if ( pageOnFront ) { + return sprintf( + /* translators: %d is a WordPress page ID. */ + __( 'Static page, page ID %d' ), + pageOnFront + ); + } + return __( 'Static page' ); + } + if ( showOnFront === 'posts' ) { + return __( 'Latest posts' ); + } + return undefined; +} + +function formatTimezone( settings: WpcomSiteSettings | undefined ) { + const timezone = getSettingString( settings, 'timezone_string' ); + if ( timezone ) { + return timezone; + } + const offset = getSettingNumber( settings, 'gmt_offset' ); + if ( offset !== undefined ) { + return sprintf( + /* translators: %s is a numeric UTC offset, for example -5 or 5.5. */ + __( 'UTC%s' ), + offset >= 0 ? `+${ offset }` : String( offset ) + ); + } + return undefined; +} + +function SettingsRow( { label, value }: { label: string; value?: string | number | null } ) { + return ( +
    +
    { label }
    +
    { value || __( 'Unknown' ) }
    +
    + ); +} + +function LiveThemeSkeleton() { + return ( +
    +

    { __( 'Theme' ) }

    +
    +
    +
    + ); +} + +function createLiveCustomizeButtons( target: RemoteTarget, isBlockTheme: boolean | undefined ) { + const openAdminPath = ( path: string ) => () => + getIpcApi().openURL( resolveLiveSiteUrl( target.site.url, path ) ); + + const blockThemeButtons: ButtonsSectionProps[ 'buttonsArray' ] = [ + { + label: __( 'Site Editor' ), + icon: desktop, + onClick: openAdminPath( '/wp-admin/site-editor.php' ), + }, + { + label: __( 'Styles' ), + icon: styles, + onClick: openAdminPath( '/wp-admin/site-editor.php?path=%2Fwp_global_styles' ), + }, + { + label: __( 'Patterns' ), + icon: symbolFilled, + onClick: openAdminPath( '/wp-admin/site-editor.php?path=%2Fpatterns' ), + }, + { + label: __( 'Navigation' ), + icon: navigation, + onClick: openAdminPath( '/wp-admin/site-editor.php?path=%2Fnavigation' ), + }, + { + label: __( 'Templates' ), + icon: layout, + onClick: openAdminPath( '/wp-admin/site-editor.php?path=%2Fwp_template' ), + }, + { + label: __( 'Pages' ), + icon: page, + onClick: openAdminPath( '/wp-admin/site-editor.php?path=%2Fpage' ), + }, + ]; + + const classicThemeButtons: ButtonsSectionProps[ 'buttonsArray' ] = [ + { + label: __( 'Customizer' ), + icon: pencil, + onClick: openAdminPath( '/wp-admin/customize.php' ), + }, + { + label: __( 'Menus' ), + icon: navigation, + onClick: openAdminPath( '/wp-admin/nav-menus.php' ), + }, + { + label: __( 'Widgets' ), + icon: widget, + onClick: openAdminPath( '/wp-admin/widgets.php' ), + }, + ]; + + return isBlockTheme === false ? classicThemeButtons : blockThemeButtons; +} + +export function WorkspaceLiveSiteOverview( { target }: { target: RemoteTarget } ) { + const [ isThumbnailError, setIsThumbnailError ] = useState( false ); + const { data: activeTheme, isLoading } = useGetActiveWpcomThemeQuery( { + siteId: target.site.id, + } ); + const themeName = activeTheme?.name || __( 'Live site' ); + const thumbnailUrl = getMshotsUrl( target.site.url ); + + useEffect( () => { + setIsThumbnailError( false ); + }, [ target.site.url ] ); + + if ( isLoading ) { + return ( +
    + +
    +
    +
    +
    + ); + } + + return ( +
    +
    +

    { __( 'Theme' ) }

    +
    + +
    +
    + { ! isThumbnailError &&

    { themeName }

    } +
    +
    +
    + +
    +
    + ); +} + +function createSettingsButtons( target: RemoteTarget ) { + const routes = [ + { + label: __( 'General' ), + icon: desktop, + path: '/wp-admin/options-general.php', + }, + { + label: __( 'Reading' ), + icon: page, + path: '/wp-admin/options-reading.php', + }, + { + label: __( 'Discussion' ), + icon: styles, + path: '/wp-admin/options-discussion.php', + }, + { + label: __( 'Permalinks' ), + icon: navigation, + path: '/wp-admin/options-permalink.php', + }, + ]; + + return routes.map( ( route ) => ( { + label: route.label, + icon: route.icon, + onClick: () => getIpcApi().openURL( resolveLiveSiteUrl( target.site.url, route.path ) ), + } ) ); +} + +export function WorkspaceLiveSiteSettings( { target }: { target: RemoteTarget } ) { + const { data: siteSettings, isLoading } = useGetWpcomSiteSettingsQuery( { + siteId: target.site.id, + } ); + const settings = siteSettings?.settings ?? {}; + + const rows = [ + { label: __( 'Environment' ), value: getTargetLabel( target ) }, + { label: __( 'Site title' ), value: siteSettings?.name ?? target.site.name }, + { label: __( 'Tagline' ), value: siteSettings?.description }, + { label: __( 'Site URL' ), value: siteSettings?.url ?? target.site.url }, + { label: __( 'Visibility' ), value: formatVisibility( siteSettings ) }, + { label: __( 'Homepage' ), value: formatHomepage( siteSettings ) }, + { label: __( 'Timezone' ), value: formatTimezone( siteSettings ) }, + { label: __( 'Language' ), value: siteSettings?.lang }, + { label: __( 'Plan' ), value: target.site.planName }, + { label: __( 'WordPress version' ), value: target.site.wpVersion }, + { + label: __( 'Manage options' ), + value: + target.site.canManageOptions === undefined + ? undefined + : target.site.canManageOptions + ? __( 'Available' ) + : __( 'Unavailable' ), + }, + { + label: __( 'Related posts' ), + value: getBooleanLikeLabel( settings.jetpack_relatedposts_enabled ), + }, + { + label: __( 'Search' ), + value: getBooleanLikeLabel( settings.jetpack_search_enabled ), + }, + { + label: __( 'Newsletter modal' ), + value: getBooleanLikeLabel( settings.wpcom_subscription_popup_enabled ), + }, + ]; + + return ( +
    +
    +

    { __( 'Settings' ) }

    +

    + { sprintf( + /* translators: %s is an environment name, such as Production or Staging. */ + __( 'These settings are read from the selected %s site.' ), + getTargetLabel( target ) + ) } +

    +
    + { isLoading ? ( +
    + ) : ( + rows.map( ( row ) => ( + + ) ) + ) } +
    +
    + +
    +
    +
    + ); +} diff --git a/apps/studio/src/modules/workspaces/components/workspace-preview.tsx b/apps/studio/src/modules/workspaces/components/workspace-preview.tsx index 35b637f8d6..abed838a51 100644 --- a/apps/studio/src/modules/workspaces/components/workspace-preview.tsx +++ b/apps/studio/src/modules/workspaces/components/workspace-preview.tsx @@ -67,10 +67,7 @@ export type WorkspacePreviewTargetOption = { type WorkspacePreviewControlsProps = { target: WorkspacePreviewTarget; - targets: WorkspacePreviewTargetOption[]; - selectedTargetId: WorkspaceTargetId; previewState: WorkspacePreviewState; - onSelectTarget: ( targetId: WorkspaceTargetId ) => void; onUpdatePreviewState: ( state: WorkspacePreviewState ) => void; }; @@ -89,8 +86,16 @@ type WorkspacePreviewPanelProps = { } ) => void; }; +type WorkspacePreviewTargetPickerProps = { + targets: WorkspacePreviewTargetOption[]; + selectedTargetId: WorkspaceTargetId; + onSelectTarget: ( targetId: WorkspaceTargetId ) => void; + ariaLabel: string; + variant?: 'url-bar' | 'header'; +}; + export const createDefaultWorkspacePreviewState = (): WorkspacePreviewState => ( { - open: true, + open: false, pathOrUrl: '/', reloadNonce: 0, width: DEFAULT_PREVIEW_WIDTH, @@ -107,72 +112,129 @@ export function resolveWorkspacePreviewUrl( siteUrl: string, pathOrUrl: string ) } } -export function WorkspacePreviewControls( { - target, +function getTargetToneClassName( target: WorkspacePreviewTargetOption | undefined ) { + if ( target?.id === 'production' || target?.isProduction ) { + return { + dot: 'bg-[#1a6928]', + url: 'bg-[#ceead6] text-[#1a6928]', + header: 'border-[#9bd3a8] bg-[#ceead6] text-[#1a6928] hover:bg-[#c3e4cc]', + }; + } + + if ( target?.id === 'staging' ) { + return { + dot: 'bg-[#d97706]', + url: 'bg-[#fef0c7] text-[#93590c]', + header: 'border-[#d97706]/30 bg-[#fff7df] text-[#93590c] hover:bg-[#fff3cc]', + }; + } + + return { + dot: 'bg-frame-text-secondary', + url: 'bg-frame-surface text-frame-text-secondary', + header: 'border-a8c-gray-5 bg-frame-surface text-frame-text-secondary hover:bg-a8c-gray-0', + }; +} + +export function WorkspacePreviewTargetPicker( { targets, selectedTargetId, - previewState, onSelectTarget, - onUpdatePreviewState, -}: WorkspacePreviewControlsProps ) { - const previewUrl = resolveWorkspacePreviewUrl( target.siteUrl, previewState.pathOrUrl ); - const displayUrl = previewState.currentUrl ?? previewUrl; + ariaLabel, + variant = 'url-bar', +}: WorkspacePreviewTargetPickerProps ) { const selectedTarget = targets.find( ( candidate ) => candidate.id === selectedTargetId ); const hasTargetPicker = targets.length > 1; const [ isTargetMenuOpen, setIsTargetMenuOpen ] = useState( false ); - const targetBadgeClassName = `shrink-0 rounded-full text-[12px] font-medium ${ - target.isProduction - ? 'bg-a8c-gray-5 text-a8c-red-50' - : 'bg-a8c-gray-5 text-frame-text-secondary' - }`; + const toneClassName = getTargetToneClassName( selectedTarget ); + const isHeaderVariant = variant === 'header'; const closeTargetMenu = () => setIsTargetMenuOpen( false ); const selectTarget = ( targetId: WorkspaceTargetId ) => { onSelectTarget( targetId ); closeTargetMenu(); }; - const targetBadge = selectedTarget?.label ? ( - hasTargetPicker ? ( - - ) : ( - - { selectedTarget.label } - - ) - ) : null; - const targetMenu = hasTargetPicker && isTargetMenuOpen && ( + const basePickerClassName = isHeaderVariant + ? `inline-flex h-10 min-w-40 items-center gap-2 rounded border px-3 text-sm font-medium shadow-sm transition focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-frame-theme ${ toneClassName.header }` + : `inline-flex h-8 min-w-32 items-center justify-center rounded-full px-3 text-[12px] font-medium focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-frame-theme ${ toneClassName.url }`; + const pickerContent = ( + <> + { isHeaderVariant && ( + <> + + { __( 'Viewing' ) } + + ) } + { selectedTarget?.label } + { hasTargetPicker && } + + ); + + if ( ! selectedTarget?.label ) { + return null; + } + + return (
    { + if ( ! event.currentTarget.contains( event.relatedTarget ) ) { + closeTargetMenu(); + } + } } > - { targets.map( ( candidate ) => ( + { hasTargetPicker ? ( - ) ) } + ) : ( + { pickerContent } + ) } + { hasTargetPicker && isTargetMenuOpen && ( +
    + { targets.map( ( candidate ) => ( + + ) ) } +
    + ) }
    ); +} + +export function WorkspacePreviewControls( { + target, + previewState, + onUpdatePreviewState, +}: WorkspacePreviewControlsProps ) { + const previewUrl = resolveWorkspacePreviewUrl( target.siteUrl, previewState.pathOrUrl ); + const displayUrl = previewState.currentUrl ?? previewUrl; const showPreview = async () => { await target.onShowPreview?.(); onUpdatePreviewState( { @@ -191,19 +253,11 @@ export function WorkspacePreviewControls( { if ( ! previewState.open ) { return ( -
    { - if ( ! event.currentTarget.contains( event.relatedTarget ) ) { - closeTargetMenu(); - } - } } - > +
    - { targetBadge }
    - { targetMenu }
    ); } @@ -252,21 +305,12 @@ export function WorkspacePreviewControls( { > -
    { - if ( ! event.currentTarget.contains( event.relatedTarget ) ) { - closeTargetMenu(); - } - } } - > +
    - { targetBadge } - + { displayUrl }
    - { targetMenu }
    - { localRunControl } + { showActivitySpinner ? ( + +
    + +
    +
    + ) : ( + localRunControl + ) }
    { recentConversations.length > 0 && (
      diff --git a/apps/studio/src/modules/workspaces/hooks/use-workspace-selection.tsx b/apps/studio/src/modules/workspaces/hooks/use-workspace-selection.tsx index 202f22ab15..a16be6abde 100644 --- a/apps/studio/src/modules/workspaces/hooks/use-workspace-selection.tsx +++ b/apps/studio/src/modules/workspaces/hooks/use-workspace-selection.tsx @@ -1,11 +1,6 @@ import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'; import { useSiteDetails } from 'src/hooks/use-site-details'; import { useSidebarWorkspaces } from 'src/modules/workspaces/hooks/use-sidebar-workspaces'; -import { - getDefaultWorkspaceTabId, - getWorkspaceTabStorageKey, - isWorkspaceTabId, -} from 'src/modules/workspaces/lib/workspace-tabs'; import type { TabName } from 'src/hooks/use-content-tabs'; import type { StudioWorkspace } from 'src/modules/workspaces/types'; @@ -25,18 +20,29 @@ const WorkspaceSelectionContext = createContext< WorkspaceSelectionContextValue undefined ); -function readSavedTabId( workspace: StudioWorkspace ): TabName | undefined { +const WORKSPACE_TAB_STORAGE_PREFIX = 'studio-workspace-tab:'; +const VALID_WORKSPACE_TAB_IDS: TabName[] = [ + 'overview', + 'sync', + 'previews', + 'import-export', + 'settings', + 'assistant', +]; + +function isWorkspaceTabId( tabId: string ): tabId is TabName { + return VALID_WORKSPACE_TAB_IDS.includes( tabId as TabName ); +} + +function getWorkspaceTabStorageKey( workspaceId: string ) { + return `${ WORKSPACE_TAB_STORAGE_PREFIX }${ workspaceId }`; +} + +function readSavedTabId( workspaceId: string ): TabName | undefined { try { - const savedTabId = localStorage.getItem( getWorkspaceTabStorageKey( workspace.id ) ); - if ( - savedTabId === 'overview' || - savedTabId === 'sync' || - savedTabId === 'settings' || - savedTabId === 'assistant' || - savedTabId === 'import-export' || - savedTabId === 'previews' - ) { - return isWorkspaceTabId( workspace, savedTabId ) ? savedTabId : undefined; + const savedTabId = localStorage.getItem( getWorkspaceTabStorageKey( workspaceId ) ); + if ( savedTabId && isWorkspaceTabId( savedTabId ) ) { + return savedTabId; } } catch { return undefined; @@ -90,13 +96,14 @@ export function WorkspaceSelectionProvider( { children }: { children: ReactNode return undefined; } - const selectedTab = selectedTabs[ selectedWorkspace.id ] ?? readSavedTabId( selectedWorkspace ); + const selectedTab = + selectedTabs[ selectedWorkspace.id ] ?? readSavedTabId( selectedWorkspace.id ); - if ( selectedTab && isWorkspaceTabId( selectedWorkspace, selectedTab ) ) { + if ( selectedTab && isWorkspaceTabId( selectedTab ) ) { return selectedTab; } - return getDefaultWorkspaceTabId( selectedWorkspace ); + return 'overview'; }, [ selectedTabs, selectedWorkspace ] ); const selectWorkspace = useCallback( @@ -117,7 +124,7 @@ export function WorkspaceSelectionProvider( { children }: { children: ReactNode const selectWorkspaceTab = useCallback( ( workspaceId: string, tabId: TabName ) => { const workspace = workspaces.find( ( candidate ) => candidate.id === workspaceId ); - if ( ! workspace || ! isWorkspaceTabId( workspace, tabId ) ) { + if ( ! workspace || ! isWorkspaceTabId( tabId ) ) { return; } diff --git a/apps/studio/src/modules/workspaces/index.ts b/apps/studio/src/modules/workspaces/index.ts index 7a003c9bb9..b2b3fbf15c 100644 --- a/apps/studio/src/modules/workspaces/index.ts +++ b/apps/studio/src/modules/workspaces/index.ts @@ -8,14 +8,6 @@ export { useWorkspaceSelection, WorkspaceSelectionProvider, } from 'src/modules/workspaces/hooks/use-workspace-selection'; -export { - getDefaultWorkspaceTabId, - getWorkspaceTabIds, - getWorkspaceTabStorageKey, - isWorkspaceTabId, - LOCAL_WORKSPACE_TAB_IDS, - REMOTE_WORKSPACE_TAB_IDS, -} from 'src/modules/workspaces/lib/workspace-tabs'; export type { BuildStudioWorkspacesInput, LocalTarget, diff --git a/apps/studio/src/modules/workspaces/lib/workspace-tabs.ts b/apps/studio/src/modules/workspaces/lib/workspace-tabs.ts deleted file mode 100644 index 448699b0d6..0000000000 --- a/apps/studio/src/modules/workspaces/lib/workspace-tabs.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { TabName } from 'src/hooks/use-content-tabs'; -import type { StudioWorkspace } from 'src/modules/workspaces/types'; - -const WORKSPACE_TAB_STORAGE_PREFIX = 'studio-workspace-tab:'; - -export const LOCAL_WORKSPACE_TAB_IDS: TabName[] = [ - 'overview', - 'assistant', - 'sync', - 'previews', - 'import-export', - 'settings', -]; - -export const REMOTE_WORKSPACE_TAB_IDS: TabName[] = [ 'assistant', 'sync', 'settings' ]; - -export function getWorkspaceTabIds( workspace: StudioWorkspace ): TabName[] { - return workspace.targets.local ? LOCAL_WORKSPACE_TAB_IDS : REMOTE_WORKSPACE_TAB_IDS; -} - -export function isWorkspaceTabId( workspace: StudioWorkspace, tabId: TabName ) { - return getWorkspaceTabIds( workspace ).includes( tabId ); -} - -export function getDefaultWorkspaceTabId( workspace: StudioWorkspace ): TabName { - return workspace.targets.local ? 'overview' : 'assistant'; -} - -export function getWorkspaceTabStorageKey( workspaceId: string ) { - return `${ WORKSPACE_TAB_STORAGE_PREFIX }${ workspaceId }`; -} diff --git a/apps/studio/src/stores/sync/wpcom-sites.ts b/apps/studio/src/stores/sync/wpcom-sites.ts index f7cb1818ea..8412207051 100644 --- a/apps/studio/src/stores/sync/wpcom-sites.ts +++ b/apps/studio/src/stores/sync/wpcom-sites.ts @@ -31,6 +31,52 @@ const SITE_FIELDS = [ 'icon', ].join( ',' ); +const activeWpcomThemeResponseSchema = z + .object( { + id: z.string().optional(), + name: z.string().optional(), + screenshot: z.string().nullable().optional(), + is_block_theme: z.boolean().optional(), + block_theme: z.boolean().optional(), + blockTheme: z.boolean().optional(), + supports_menus: z.boolean().optional(), + supports_widgets: z.boolean().optional(), + } ) + .passthrough() + .transform( ( theme ) => ( { + id: theme.id, + name: theme.name, + screenshotUrl: theme.screenshot || undefined, + isBlockTheme: theme.is_block_theme ?? theme.block_theme ?? theme.blockTheme, + supportsMenus: theme.supports_menus, + supportsWidgets: theme.supports_widgets, + } ) ); + +export type WpcomActiveTheme = z.infer< typeof activeWpcomThemeResponseSchema >; + +const wpcomSiteSettingsResponseSchema = z + .object( { + ID: z.number().optional(), + name: z.string().optional(), + description: z.string().optional(), + URL: z.string().optional(), + lang: z.string().optional(), + locale_variant: z.string().nullable().optional(), + settings: z.record( z.string(), z.unknown() ).optional(), + } ) + .passthrough() + .transform( ( response ) => ( { + id: response.ID, + name: response.name, + description: response.description, + url: response.URL, + lang: response.lang, + localeVariant: response.locale_variant ?? undefined, + settings: response.settings ?? {}, + } ) ); + +export type WpcomSiteSettings = z.infer< typeof wpcomSiteSettingsResponseSchema >; + export const wpcomSitesApi = createApi( { reducerPath: 'wpcomSitesApi', baseQuery: fetchBaseQuery(), @@ -252,15 +298,81 @@ export const wpcomSitesApi = createApi( { { type: 'WpComSites', id: arg.siteId }, ], } ), + getActiveWpcomTheme: builder.query< WpcomActiveTheme, { 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', + path: `/sites/${ siteId }/themes/mine`, + } ); + + return { data: activeWpcomThemeResponseSchema.parse( response ) }; + } catch ( error ) { + Sentry.captureException( error ); + console.error( error ); + return { + error: { + status: 500, + data: error, + }, + }; + } + }, + keepUnusedDataFor: 300, + providesTags: ( _result, _error, arg ) => [ + { type: 'WpComSites', userId: arg.userId }, + { type: 'WpComSites', id: arg.siteId }, + ], + } ), + getWpcomSiteSettings: builder.query< WpcomSiteSettings, { 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 }/settings`, + } ); + + return { data: wpcomSiteSettingsResponseSchema.parse( response ) }; + } catch ( error ) { + Sentry.captureException( error ); + console.error( error ); + return { + error: { + status: 500, + data: error, + }, + }; + } + }, + keepUnusedDataFor: 300, + providesTags: ( _result, _error, arg ) => [ + { type: 'WpComSites', userId: arg.userId }, + { type: 'WpComSites', id: arg.siteId }, + ], + } ), } ), } ); const { useGetWpComSitesQuery: useGetWpComSitesQueryBase, useGetPhpVersionQuery: useGetPhpVersionQueryBase, + useGetActiveWpcomThemeQuery: useGetActiveWpcomThemeQueryBase, + useGetWpcomSiteSettingsQuery: useGetWpcomSiteSettingsQueryBase, } = wpcomSitesApi; // Wrap the query hook with offline check // Authentication is already handled in queryFn which checks wpcomClient export const useGetWpComSitesQuery = withOfflineCheck( useGetWpComSitesQueryBase ); export const useGetPhpVersionQuery = withOfflineCheck( useGetPhpVersionQueryBase ); +export const useGetActiveWpcomThemeQuery = withOfflineCheck( useGetActiveWpcomThemeQueryBase ); +export const useGetWpcomSiteSettingsQuery = withOfflineCheck( useGetWpcomSiteSettingsQueryBase );