Skip to content
9 changes: 3 additions & 6 deletions src/components/assistant-code-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,7 @@ import { ChatMessageProps } from './chat-message';
import { CopyTextButton } from './copy-text-button';
import { ExecuteIcon } from './icons/execute';

type ContextProps = Pick<
ChatMessageProps,
'blocks' | 'updateMessage' | 'projectPath' | 'messageId'
>;
type ContextProps = Pick< ChatMessageProps, 'blocks' | 'updateMessage' | 'siteId' | 'messageId' >;

type CodeBlockProps = JSX.IntrinsicElements[ 'code' ] & ExtraProps;

Expand All @@ -24,7 +21,7 @@ export default function createCodeComponent( contextProps: ContextProps ) {
function CodeBlock( props: ContextProps & CodeBlockProps ) {
const content = String( props.children ).trim();
const isValidWpCliCommand = useIsValidWpCliInline( content );
const { node, blocks, updateMessage, projectPath, messageId, ...htmlAttributes } = props;
const { node, blocks, updateMessage, siteId, messageId, ...htmlAttributes } = props;
const {
cliOutput,
cliStatus,
Expand All @@ -34,7 +31,7 @@ function CodeBlock( props: ContextProps & CodeBlockProps ) {
setCliOutput,
setCliStatus,
setCliTime,
} = useExecuteWPCLI( content, projectPath, updateMessage, messageId );
} = useExecuteWPCLI( content, siteId, updateMessage, messageId );

useEffect( () => {
if ( blocks ) {
Expand Down
5 changes: 2 additions & 3 deletions src/components/chat-message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ export interface ChatMessageProps {
id: string;
messageId?: number;
className?: string;
projectPath?: string;
siteId?: string;
blocks?: {
cliOutput?: string;
Expand All @@ -35,7 +34,7 @@ export const ChatMessage = ( {
messageId,
isUser,
className,
projectPath,
siteId,
blocks,
updateMessage,
isUnauthenticated,
Expand Down Expand Up @@ -73,7 +72,7 @@ export const ChatMessage = ( {
code: createCodeComponent( {
blocks,
messageId,
projectPath,
siteId,
updateMessage,
} ),
img: () => null,
Expand Down
12 changes: 6 additions & 6 deletions src/components/content-tab-assistant.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ const AuthenticatedView = memo(
messages,
isAssistantThinking,
updateMessage,
path,
siteId,
}: {
messages: MessageType[];
isAssistantThinking: boolean;
Expand All @@ -77,7 +77,7 @@ const AuthenticatedView = memo(
cliStatus: 'success' | 'error',
cliTime: string
) => void;
path: string;
siteId: string;
} ) => {
const endOfMessagesRef = useRef< HTMLDivElement >( null );

Expand All @@ -98,7 +98,7 @@ const AuthenticatedView = memo(
key={ index }
id={ `message-chat-${ index }` }
isUser={ message.role === 'user' }
projectPath={ path }
siteId={ siteId }
updateMessage={ updateMessage }
messageId={ message.id }
blocks={ message.blocks }
Expand Down Expand Up @@ -239,7 +239,7 @@ export function ContentTabAssistant( { selectedSite }: ContentTabAssistantProps
messages={ messages }
isAssistantThinking={ isAssistantThinking }
updateMessage={ updateMessage }
path={ selectedSite.path }
siteId={ selectedSite.id }
/>
) }
<OfflineModeView />
Expand All @@ -259,7 +259,7 @@ export function ContentTabAssistant( { selectedSite }: ContentTabAssistantProps
messages={ messages }
isAssistantThinking={ isAssistantThinking }
updateMessage={ updateMessage }
path={ selectedSite.path }
siteId={ selectedSite.id }
/>
<UsageLimitReached />
</>
Expand All @@ -278,7 +278,7 @@ export function ContentTabAssistant( { selectedSite }: ContentTabAssistantProps
messages={ messages }
isAssistantThinking={ isAssistantThinking }
updateMessage={ updateMessage }
path={ selectedSite.path }
siteId={ selectedSite.id }
/>
<ClearHistoryReminder lastMessage={ lastMessage } clearInput={ clearInput } />
</>
Expand Down
19 changes: 7 additions & 12 deletions src/hooks/use-chat-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@ export const ChatProvider: React.FC< ChatProviderProps > = ( { children } ) => {
const [ pluginsList, setPluginsList ] = useState< Record< string, string[] > >( {} );
const [ themesList, setThemesList ] = useState< Record< string, string[] > >( {} );
const numberOfSites = sites?.length || 0;
const sitePath = selectedSite?.path || '';
const sitePort = selectedSite?.port || '';

const { selectedThemeDetails: themeDetails } = useThemeDetails();
Expand All @@ -73,9 +72,9 @@ export const ChatProvider: React.FC< ChatProviderProps > = ( { children } ) => {
return installedApps[ app as keyof InstalledApps ];
} );

const fetchPluginList = useCallback( async ( path: string ) => {
const fetchPluginList = useCallback( async ( siteId: string ) => {
const { stdout, stderr } = await getIpcApi().executeWPCLiInline( {
projectPath: path,
siteId,
args: 'plugin list --format=json --status=active',
} );
if ( stderr ) {
Expand All @@ -84,9 +83,9 @@ export const ChatProvider: React.FC< ChatProviderProps > = ( { children } ) => {
return parseWpCliOutput( stdout, [] );
}, [] );

const fetchThemeList = useCallback( async ( path: string ) => {
const fetchThemeList = useCallback( async ( siteId: string ) => {
const { stdout, stderr } = await getIpcApi().executeWPCLiInline( {
projectPath: path,
siteId,
args: 'theme list --format=json',
} );
if ( stderr ) {
Expand All @@ -104,10 +103,7 @@ export const ChatProvider: React.FC< ChatProviderProps > = ( { children } ) => {
}
setInitialLoad( ( prev ) => ( { ...prev, [ siteId ]: true } ) );
try {
const result = await Promise.all( [
fetchPluginList( sitePath ),
fetchThemeList( sitePath ),
] );
const result = await Promise.all( [ fetchPluginList( siteId ), fetchThemeList( siteId ) ] );
if ( isCurrent ) {
setPluginsList( ( prev ) => ( { ...prev, [ siteId ]: result[ 0 ] } ) );
setThemesList( ( prev ) => ( { ...prev, [ siteId ]: result[ 1 ] } ) );
Expand Down Expand Up @@ -140,16 +136,15 @@ export const ChatProvider: React.FC< ChatProviderProps > = ( { children } ) => {
selectedSite,
sites,
themesList,
sitePath,
] );

useWindowListener( 'focus', async () => {
// When the window is focused, we need to kick off a request to refetch the theme details, if server is running.
if ( ! selectedSite?.id || selectedSite.running === false ) {
return;
}
const plugins = await fetchPluginList( sitePath );
const themes = await fetchThemeList( sitePath );
const plugins = await fetchPluginList( selectedSite.id );
const themes = await fetchThemeList( selectedSite.id );
setPluginsList( ( prev ) => ( { ...prev, [ selectedSite.id ]: plugins } ) );
setThemesList( ( prev ) => ( { ...prev, [ selectedSite.id ]: themes } ) );
} );
Expand Down
6 changes: 3 additions & 3 deletions src/hooks/use-execute-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { getIpcApi } from '../lib/get-ipc-api';

export function useExecuteWPCLI(
content: string,
projectPath: string | undefined,
siteId: string | undefined,
updateMessage:
| ( (
id: number,
Expand All @@ -26,7 +26,7 @@ export function useExecuteWPCLI(
const startTime = Date.now();
const args = content.split( ' ' ).slice( 1 );
const result = await getIpcApi().executeWPCLiInline( {
projectPath: projectPath || '',
siteId: siteId || '',
args: args.join( ' ' ),
} );

Expand Down Expand Up @@ -56,7 +56,7 @@ export function useExecuteWPCLI(
);
}
}, 2300 );
}, [ content, messageId, projectPath, updateMessage ] );
}, [ content, messageId, siteId, updateMessage ] );

return {
cliOutput,
Expand Down
25 changes: 10 additions & 15 deletions src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import nodePath from 'path';
import * as Sentry from '@sentry/electron/main';
import archiver from 'archiver';
import { copySync } from 'fs-extra';
import { parse } from 'shell-quote';
import { SQLITE_FILENAME, DEFAULT_PHP_VERSION } from '../vendor/wp-now/src/constants';
import { downloadSqliteIntegrationPlugin } from '../vendor/wp-now/src/download';
import { LIMIT_ARCHIVE_SIZE } from './constants';
Expand All @@ -34,7 +33,6 @@ import {
removeLegacySqliteIntegrationPlugin,
} from './lib/sqlite-versions';
import * as windowsHelpers from './lib/windows-helpers';
import WpCliProcess from './lib/wp-cli-process';
import { writeLogToFile, type LogLevel } from './logging';
import { popupMenu } from './menu';
import { SiteServer, createSiteWorkingDirectory } from './site-server';
Expand All @@ -45,6 +43,7 @@ import {
getSiteThumbnailPath,
} from './storage/paths';
import { loadUserData, saveUserData } from './storage/user-data';
import type { WpCliResult } from './lib/wp-cli-process';

const TEMP_DIR = nodePath.join( app.getPath( 'temp' ), 'com.wordpress.studio' ) + nodePath.sep;
if ( ! fs.existsSync( TEMP_DIR ) ) {
Expand Down Expand Up @@ -571,20 +570,16 @@ export async function saveOnboarding(

export async function executeWPCLiInline(
_event: IpcMainInvokeEvent,
{ projectPath, args }: { projectPath: string; args: string }
) {
const wpCliArgs = parse( args );

// The parsing of arguments can include shell operators like `>` or `||` that the app don't support.
const isValidCommand = wpCliArgs.every(
( arg: unknown ) => typeof arg === 'string' || arg instanceof String
);
if ( ! isValidCommand ) {
throw Error( `Can't execute wp-cli command with arguments: ${ args }` );
{ siteId, args }: { siteId: string; args: string }
): Promise< WpCliResult > {
if ( SiteServer.isDeleted( siteId ) ) {
Comment thread
dcalhoun marked this conversation as resolved.
return { stdout: '', stderr: `Cannot execute command on deleted site ${ siteId }` };
}

const process = new WpCliProcess();
return await process.execute( projectPath, wpCliArgs as string[] );
const server = SiteServer.get( siteId );
if ( ! server ) {
throw new Error( 'Site not found.' );
}
return server.executeWpCliCommand( args );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this way of executing wp-cli commands

}

export async function getThumbnailData( _event: IpcMainInvokeEvent, id: string ) {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/site-server-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export default class SiteServerProcess {
throw Error( 'Server process is not running' );
}

const messageId = +this.lastMessageId;
const messageId = this.lastMessageId++;
process.postMessage( { message, messageId, data } );
Comment on lines 85 to 86

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch.

return messageId;
}
Expand Down
67 changes: 45 additions & 22 deletions src/lib/wp-cli-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,34 +8,27 @@ import { executeWPCli } from '../../vendor/wp-now/src/execute-wp-cli';
declare const WP_CLI_PROCESS_MODULE_PATH: string;

export type MessageName = 'execute';
export type WpCliResult = ReturnType< typeof executeWPCli >;
export type MessageCanceled = { error: Error; canceled: boolean };

const DEFAULT_RESPONSE_TIMEOUT = 120000;

export default class WpCliProcess {
lastMessageId = 0;
process?: UtilityProcess;
ongoingMessages: Record< number, { cancelHandler: () => void } > = {};
projectPath: string;

async execute(
projectPath: string,
args: string[]
): Promise< ReturnType< typeof executeWPCli > > {
constructor( projectPath: string ) {
this.projectPath = projectPath;
}

async init(): Promise< void > {
return new Promise( ( resolve, reject ) => {
const spawnListener = async () => {
const messageId = this.sendMessage( 'execute', { projectPath, args } );
try {
const response = await this.waitForResponse< ReturnType< typeof executeWPCli > >(
'execute',
messageId
);
// Removing exit listener as we only need it upon starting
this.process?.off( 'exit', exitListener );
resolve( response );
} catch ( error ) {
reject( error );
} finally {
// The process is automatically killed after the execution
this.#killProcess();
}
// Removing exit listener as we only need it upon starting
this.process?.off( 'exit', exitListener );
resolve();
};
const exitListener = ( code: number ) => {
if ( code !== 0 ) {
Expand All @@ -59,6 +52,12 @@ export default class WpCliProcess {
} );
}

async execute( args: string[] ): Promise< WpCliResult > {
const message = 'execute';
const messageId = this.sendMessage( message, { projectPath: this.projectPath, args } );
return await this.waitForResponse( message, messageId );
}

async stop() {
await this.#killProcess();
}
Expand All @@ -69,7 +68,7 @@ export default class WpCliProcess {
throw Error( 'wp-cli process is not running' );
}

const messageId = +this.lastMessageId;
const messageId = this.lastMessageId++;
process.postMessage( { message, messageId, data } );
return messageId;
}
Expand All @@ -83,6 +82,11 @@ export default class WpCliProcess {
if ( ! process ) {
throw Error( 'wp-cli process is not running' );
}
if ( this.ongoingMessages[ originalMessageId ] ) {
throw Error(
`The 'waitForResponse' function was already called for message ID ${ originalMessageId } from the message '${ originalMessage }'. 'waitForResponse' may only be called once per message ID.`
);
}

return new Promise( ( resolve, reject ) => {
const handler = ( {
Expand All @@ -101,17 +105,28 @@ export default class WpCliProcess {
}
process.removeListener( 'message', handler );
clearTimeout( timeoutId );
delete this.ongoingMessages[ originalMessageId ];
if ( typeof error !== 'undefined' ) {
reject( error );
return;
}
resolve( data );
};

const timeoutId = setTimeout( () => {
const timeoutHandler = () => {
reject( new Error( `Request for message ${ originalMessage } timed out` ) );
process.removeListener( 'message', handler );
}, timeout );
};
const timeoutId = setTimeout( timeoutHandler, timeout );
const cancelHandler = () => {
clearTimeout( timeoutId );
reject( {
error: new Error( `Request for message ${ originalMessage } was canceled` ),
canceled: true,
} as MessageCanceled );
process.removeListener( 'message', handler );
};
this.ongoingMessages[ originalMessageId ] = { cancelHandler };

process.addListener( 'message', handler );
} );
Expand All @@ -123,6 +138,8 @@ export default class WpCliProcess {
throw Error( 'wp-cli process is not running' );
}

this.#cancelOngoingMessages();

return new Promise< void >( ( resolve, reject ) => {
process.once( 'exit', ( code ) => {
if ( code !== 0 ) {
Expand All @@ -136,4 +153,10 @@ export default class WpCliProcess {
Sentry.captureException( error );
} );
}

#cancelOngoingMessages() {
Object.values( this.ongoingMessages ).forEach( ( { cancelHandler } ) => {
cancelHandler();
} );
}
}
Loading