Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
87f656b
Replace the site dropdown with a permanent site header
shaunandrews Jul 30, 2026
56ad63c
Surface push and pull as separate buttons, with previews and connecti…
shaunandrews Jul 31, 2026
208a732
Reduce the site header to two icon actions, with progress in the butt…
shaunandrews Jul 31, 2026
6769f2e
Make the header and tab icons follow their button's text colour
shaunandrews Jul 31, 2026
3e2ac1c
Reduce the sync button tooltips to a single line
shaunandrews Jul 31, 2026
6214b3a
Choose a site to publish to in a modal, sharing onboarding's connect …
shaunandrews Jul 31, 2026
211e676
Keep a running toast open for the whole push, pull, or preview
shaunandrews Jul 31, 2026
8800f68
Drive real toasts from the tweaks panel's scripted runs
shaunandrews Jul 31, 2026
bd41ecb
Fix the crash when a toast is replaced in place with a different shape
shaunandrews Jul 31, 2026
998ce5c
Rebuild the tweak scenarios around the header as it stands
shaunandrews Jul 31, 2026
0bcbfd9
Replace the push and pull buttons with one Sync action and a dialog
shaunandrews Jul 31, 2026
46138c6
Move preview links from a tab into a Share dialog in the site header
shaunandrews Jul 31, 2026
1ed2286
Replace design tokens that were never in the design system
shaunandrews Jul 31, 2026
20f9a28
Let the agentic UI window shrink to 420px, keeping 712 for the defaul…
shaunandrews Jul 31, 2026
e67cbf7
Point Reload and Toggle DevTools at the app window instead of the foc…
shaunandrews Jul 31, 2026
97a12fd
Raise toasts above the modal scrim so a running sync stays readable
shaunandrews Jul 31, 2026
d1d4444
Carry selective-sync options and file-tree lookups through to the CLI
shaunandrews Jul 31, 2026
b34c4a5
Rebuild the Share dialog around live sites and preview links
shaunandrews Jul 31, 2026
0dfb256
Choose exactly what a sync carries with a file tree, and drop the twe…
shaunandrews Jul 31, 2026
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions apps/cli/commands/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ const logger = new Logger< LoggerAction >();
export async function runCommand(
siteFolder: string,
syncOptions?: SyncOption[],
siteIdentifier?: string
siteIdentifier?: string,
syncIncludePathList?: string[]
): Promise< void > {
let site: SiteData | undefined;
let wasServerRunning = false;
Expand Down Expand Up @@ -86,6 +87,7 @@ export async function runCommand(

if ( syncOptions ) {
optionsToSync = syncOptions;
includePathList = syncIncludePathList;
} else {
logger.reportStart( LoggerAction.FETCH_REMOTE_SITES, __( 'Fetching file tree…' ) );
const { tree } = await fetchPullTree( token.accessToken, remoteSite.id );
Expand Down Expand Up @@ -253,11 +255,30 @@ export const registerCommand = ( yargs: StudioArgv ) => {
.option( 'remote-site', {
type: 'string',
description: __( 'Remote site URL or ID' ),
} )
.option( 'include-path-list', {
type: 'string',
description: __(
'Comma-separated backup node ids to pull when using the "paths" option'
),
hidden: true,
coerce: ( val: string | undefined ) =>
val !== undefined
? val
.split( ',' )
.map( ( item ) => item.trim() )
.filter( Boolean )
: undefined,
} );
},
handler: async ( argv ) => {
try {
await runCommand( argv.path, argv.options as SyncOption[] | undefined, argv.remoteSite );
await runCommand(
argv.path,
argv.options as SyncOption[] | undefined,
argv.remoteSite,
argv.includePathList as string[] | undefined
);
} catch ( error ) {
if ( error instanceof LoggerError ) {
logger.reportError( error );
Expand Down
148 changes: 130 additions & 18 deletions apps/local/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,11 @@ import {
updateSharedSession,
} from '@studio/common/lib/shared-config';
import { fetchStudioAssistantQuota } from '@studio/common/lib/studio-assistant-quota';
import { fetchSyncableSites } from '@studio/common/lib/sync/sync-api';
import { fetchLatestRewindId, fetchSyncableSites } from '@studio/common/lib/sync/sync-api';
import { detectInstalledApps } from '@studio/common/lib/user-settings/installed-apps';
import { isWordPressDevVersion } from '@studio/common/lib/wordpress-version-utils';
import wpcomFactory from '@studio/common/lib/wpcom-factory';
import wpcomXhrRequest from '@studio/common/lib/wpcom-xhr-request-factory';
import {
cleanupBlueprintTempDir,
extractBlueprintUpload,
Expand All @@ -68,10 +70,16 @@ import { createSnapshotManager, fetchSnapshots } from '@studio/common/sites/snap
import { pullSite, pushSite } from '@studio/common/sites/sync';
import express from 'express';
import { rateLimit } from 'express-rate-limit';
import { z } from 'zod';
import { isEditor, isTerminal, openInEditor, openInTerminal, openPath } from './open-in-os';
import type { SiteListItem } from '@studio/common/lib/cli-events';
import type { EditSiteOptions } from '@studio/common/sites/edit';
import type { SyncSite } from '@studio/common/types/sync';
import type {
PullSyncOptions,
PushSiteProgress,
PushSyncOptions,
SyncSite,
} from '@studio/common/types/sync';
import type { Request, Response } from 'express';

/**
Expand Down Expand Up @@ -1075,7 +1083,10 @@ export async function startLocalServer( options: LocalServerOptions ): Promise<
api.post(
'/sites/:id/pull',
asyncHandler( async ( req: Request, res: Response ) => {
const { remoteSiteId } = req.body as { remoteSiteId?: number };
const { remoteSiteId, options } = req.body as {
remoteSiteId?: number;
options?: PullSyncOptions;
};
if ( ! remoteSiteId ) {
res.status( 400 ).json( { error: 'remoteSiteId is required' } );
return;
Expand All @@ -1085,23 +1096,108 @@ export async function startLocalServer( options: LocalServerOptions ): Promise<
res.status( 404 ).json( { error: `Site ${ req.params.id } not found` } );
return;
}
await pullSite( execute, site.path, remoteSiteId, ( progress ) => {
sseSend( {
channel: 'sync-pull',
payload: { ...progress, siteId: req.params.id, remoteSiteId },
} );
} );
await pullSite(
execute,
site.path,
remoteSiteId,
( progress ) => {
sseSend( {
channel: 'sync-pull',
payload: { ...progress, siteId: req.params.id, remoteSiteId },
} );
},
options
);
res.sendStatus( 204 );
} )
);

// Selective-sync lookups for the agentic UI's sync dialog: the latest
// backup (rewind) id, the remote backup file tree under it, and the live
// site's hosting PHP version.
api.get(
'/wpcom/sites/:remoteSiteId/latest-rewind-id',
asyncHandler( async ( req: Request, res: Response ) => {
const token = await readAuthToken();
if ( ! token?.accessToken ) {
res.status( 401 ).json( { error: 'Authentication required.' } );
return;
}
try {
res.json(
await fetchLatestRewindId( token.accessToken, Number( req.params.remoteSiteId ) )
);
} catch {
res.json( null );
}
} )
);

api.get(
'/wpcom/sites/:remoteSiteId/remote-file-tree',
asyncHandler( async ( req: Request, res: Response ) => {
const token = await readAuthToken();
if ( ! token?.accessToken ) {
res.status( 401 ).json( { error: 'Authentication required.' } );
return;
}
const { rewindId, path: treePath } = req.query as { rewindId?: string; path?: string };
if ( ! rewindId || ! treePath ) {
res.status( 400 ).json( { error: 'rewindId and path are required' } );
return;
}
const wpcom = wpcomFactory( token.accessToken, wpcomXhrRequest );
const rawResponse = await wpcom.req.post( {
path: `/sites/${ Number( req.params.remoteSiteId ) }/rewind/backup/ls`,
apiNamespace: 'wpcom/v2',
body: { backup_id: rewindId, path: treePath },
} );
const parsed = z
.object( {
ok: z.boolean(),
error: z.string().optional(),
contents: z.record( z.string(), z.unknown() ).optional(),
} )
.parse( rawResponse );
if ( ! parsed.ok ) {
res.status( 502 ).json( { error: parsed.error || 'Failed to fetch remote file tree' } );
return;
}
res.json( parsed.contents ?? {} );
} )
);

api.get(
'/wpcom/sites/:remoteSiteId/hosting-php-version',
asyncHandler( async ( req: Request, res: Response ) => {
const token = await readAuthToken();
if ( ! token?.accessToken ) {
res.status( 401 ).json( { error: 'Authentication required.' } );
return;
}
try {
const wpcom = wpcomFactory( token.accessToken, wpcomXhrRequest );
const response = await wpcom.req.get( {
apiNamespace: 'wpcom/v2',
path: `/sites/${ Number( req.params.remoteSiteId ) }/hosting/php-version`,
} );
res.json( z.string().parse( response ) );
} catch {
res.json( null );
}
} )
);

// Push the local site to its connected WordPress.com live site. Long-running
// (export → upload → import); progress streams on the SSE `sync` channel.
// Resolves once the import is initiated.
// (export → upload → import); progress streams on the SSE `sync-push`
// channel. Resolves once the import is initiated.
api.post(
'/sites/:id/push',
asyncHandler( async ( req: Request, res: Response ) => {
const { remoteSiteId } = req.body as { remoteSiteId?: number };
const { remoteSiteId, options } = req.body as {
remoteSiteId?: number;
options?: PushSyncOptions;
};
if ( ! remoteSiteId ) {
res.status( 400 ).json( { error: 'remoteSiteId is required' } );
return;
Expand All @@ -1116,17 +1212,33 @@ export async function startLocalServer( options: LocalServerOptions ): Promise<
res.status( 404 ).json( { error: `Site ${ req.params.id } not found` } );
return;
}
// The upload percentage is the only number a push produces, so it's
// carried across pause/resume rather than resetting to zero.
let uploadProgress: number | undefined;
const sendPushProgress = ( progress: PushSiteProgress ) =>
sseSend( {
channel: 'sync-push',
payload: { ...progress, siteId: req.params.id, remoteSiteId },
} );

await pushSite(
{
executeCliCommand: execute,
accessToken: token.accessToken,
emit: ( output ) =>
sseSend( {
channel: 'sync',
payload: { ...output, siteId: req.params.id, remoteSiteId },
} ),
emit: ( output ) => {
if ( output.kind === 'phase' ) {
sendPushProgress( { phase: output.phase, progress: uploadProgress } );
} else if ( output.kind === 'upload-progress' ) {
uploadProgress = output.progress;
sendPushProgress( { phase: 'uploading', progress: output.progress } );
} else if ( output.kind === 'network-paused' ) {
sendPushProgress( { phase: 'paused', progress: uploadProgress } );
} else if ( output.kind === 'resumed' ) {
sendPushProgress( { phase: 'uploading', progress: uploadProgress } );
}
},
},
{ sitePath: site.path, remoteSiteId }
{ sitePath: site.path, remoteSiteId, options }
);
res.sendStatus( 204 );
} )
Expand Down
6 changes: 6 additions & 0 deletions apps/studio/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ export const SIDEBAR_WIDTH = 208;
export const SIDEBAR_MIN_WIDTH = 200;
export const SIDEBAR_MAX_WIDTH = 400;
export const MAIN_MIN_WIDTH = 712;
// The agentic UI collapses to a single chat column — both the sidebar and the
// preview panel can be closed — so it goes far narrower than the default
// renderer's two-pane layout. The floor is what the site header still needs:
// the macOS traffic lights, the site icon, and the Share + Sync actions, with
// the site name free to truncate between them.
export const AGENTIC_MIN_WIDTH = 420;
export const LOCAL_STORAGE_SIDEBAR_WIDTH_KEY = 'sidebar_width';
export const APP_CHROME_SPACING = 10;
export const MIN_WIDTH_CLASS_TO_MEASURE = 'app-measure-tabs-width';
Expand Down
3 changes: 3 additions & 0 deletions apps/studio/src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,9 @@ export {
exportSiteForPush,
fetchSyncableWpcomSites,
getConnectedWpcomSites,
getHostingPhpVersion,
getLatestRewindId,
listRemoteFileTree,
pauseSyncUpload,
pullSiteFromLive,
pushArchive,
Expand Down
3 changes: 2 additions & 1 deletion apps/studio/src/ipc-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { AgentRunEvent } from '@studio/common/ai/agent-events';
import type { AiSessionPlacementUpdatedEvent } from '@studio/common/ai/sessions/placement';
import type { RemoteSessionStatus } from '@studio/common/lib/remote-session';
import type { StoredAuthToken } from '@studio/common/lib/shared-config';
import type { PullSiteProgress } from '@studio/common/types/sync';
import type { PullSiteProgress, PushSiteProgress } from '@studio/common/types/sync';

type SnapshotEventData = {
action: PreviewCommandLoggerAction;
Expand Down Expand Up @@ -40,6 +40,7 @@ export interface IpcEvents {
'sync-upload-progress': [ { selectedSiteId: string; remoteSiteId: number; progress: number } ];
'sync-upload-manually-paused': [ { selectedSiteId: string; remoteSiteId: number } ];
'sync-pull-progress': [ PullSiteProgress & { siteId: string } ];
'sync-push-progress': [ PushSiteProgress & { siteId: string } ];
'snapshot-error': [ { operationId: crypto.UUID; data: SnapshotEventData } ];
'snapshot-fatal-error': [ { operationId: crypto.UUID; data: { message: string } } ];
'snapshot-output': [ { operationId: crypto.UUID; data: SnapshotEventData } ];
Expand Down
36 changes: 32 additions & 4 deletions apps/studio/src/main-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { portFinder } from '@studio/common/lib/port-finder';
import {
DEFAULT_HEIGHT,
DEFAULT_WIDTH,
AGENTIC_MIN_WIDTH,
MACOS_TRAFFIC_LIGHT_POSITION,
MAIN_MIN_HEIGHT,
MAIN_MIN_WIDTH,
Expand Down Expand Up @@ -90,6 +91,14 @@ async function loadRendererLocation( window: BrowserWindow, location: RendererLo

export async function loadMainWindowRenderer( window: BrowserWindow ): Promise< void > {
await loadRendererLocation( window, getRendererLocation( getPreferredStudioUiMode() ) );
// Switching renderers changes the floor. Growing it (agentic → default)
// also widens a window that is already below the new minimum.
const minWidth = getMinWindowWidth();
window.setMinimumSize( minWidth, MAIN_MIN_HEIGHT );
const [ width, height ] = window.getSize();
if ( width < minWidth ) {
window.setSize( minWidth, height, true );
}
if ( process.platform === 'win32' || process.platform === 'linux' ) {
window.setTitleBarOverlay( getTitleBarOverlayOptions() );
}
Expand Down Expand Up @@ -139,8 +148,14 @@ function initializePortFinder( sites: SiteDetails[] ) {
} );
}

// Each renderer has its own floor, so the window can't be dragged narrower
// than whichever one is on screen.
function getMinWindowWidth(): number {
return getPreferredStudioUiMode() === 'agentic' ? AGENTIC_MIN_WIDTH : MAIN_MIN_WIDTH;
}

function isValidWindowBounds( bounds: WindowBounds ): boolean {
if ( bounds.width < MAIN_MIN_WIDTH || bounds.height < MAIN_MIN_HEIGHT ) {
if ( bounds.width < getMinWindowWidth() || bounds.height < MAIN_MIN_HEIGHT ) {
return false;
}

Expand Down Expand Up @@ -170,7 +185,7 @@ export async function createMainWindow(): Promise< BrowserWindow > {
width: DEFAULT_WIDTH,
backgroundColor: 'rgba(30, 30, 30, 1)',
minHeight: MAIN_MIN_HEIGHT,
minWidth: MAIN_MIN_WIDTH,
minWidth: getMinWindowWidth(),
webPreferences: {
preload: path.join( __dirname, '../preload/preload.js' ),
webSecurity: process.env.NODE_ENV !== 'development',
Expand Down Expand Up @@ -215,13 +230,26 @@ export async function createMainWindow(): Promise< BrowserWindow > {
mainWindow.setFullScreen( true );
}

void loadRendererLocation( mainWindow, getRendererLocation( getPreferredStudioUiMode() ) );
const rendererLoaded = loadRendererLocation(
mainWindow,
getRendererLocation( getPreferredStudioUiMode() )
);

// DO NOT COMMIT — local fix for STU-2171, kept out of the STU-2162 branch.
// It belongs in its own PR; drop it from any commit made here.
//
// Open the DevTools if the user had it open last time they used the app.
// During development the dev tools default to open.
//
// This waits for the renderer to finish loading. Electron 43 delivers the
// sandboxed preload's startup data as part of the initial page load;
// attaching DevTools while that is still in flight leaves
// `binding.startupData` null, so the preload never runs and the renderer
// comes up with no `window.ipcApi` — a blank window whose only symptom is
// an "IPC API not available" error.
void loadUserData().then( ( userData ) => {
setupDevTools( mainWindow, userData.devToolsOpen );
initializePortFinder( SiteServer.getAllDetails() );
void rendererLoaded.then( () => setupDevTools( mainWindow, userData.devToolsOpen ) );
} );

mainWindow.webContents.on( 'devtools-opened', async () => {
Expand Down
Loading
Loading