Skip to content
1 change: 1 addition & 0 deletions apps/studio/src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ import type { WpCliResult } from 'src/site-server';

export {
isStudioCliInstalled,
isStudioCliExternallyManaged,
installStudioCli,
uninstallStudioCli,
} from 'src/modules/cli/lib/ipc-handlers';
Expand Down
6 changes: 6 additions & 0 deletions apps/studio/src/modules/cli/lib/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { WindowsCliInstallationManager } from 'src/modules/cli/lib/windows-insta
*/
export interface StudioCliInstallationManager {
isCliInstalled(): Promise< boolean >;
isCliExternallyManaged(): Promise< boolean >;
installCliWithConfirmation(): Promise< void >;
uninstallCliWithConfirmation(): Promise< void >;
}
Expand All @@ -32,6 +33,11 @@ export async function isStudioCliInstalled(): Promise< boolean > {
return await manager.isCliInstalled();
}

export async function isStudioCliExternallyManaged(): Promise< boolean > {
const manager = getCliInstallationManager();
return await manager.isCliExternallyManaged();
}

export async function installStudioCli(): Promise< void > {
if ( process.env.NODE_ENV !== 'production' ) {
const mainWindow = await getMainWindow();
Expand Down
4 changes: 4 additions & 0 deletions apps/studio/src/modules/cli/lib/unix-installation-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ export class UnixCliInstallationManager implements StudioCliInstallationManager
return await doesSymlinkLeadToPackagedCli( cliSymlinkPath, this.config.prodCliPackagedPath );
}

async isCliExternallyManaged(): Promise< boolean > {
return await this.isExternallyManagedCli( cliSymlinkPath );
}

async installCliWithConfirmation(): Promise< void > {
try {
await this.installCli();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ export class WindowsCliInstallationManager implements StudioCliInstallationManag
}
}

async isCliExternallyManaged(): Promise< boolean > {
try {
return await this.isStandaloneCli();
} catch ( error ) {
console.error( 'Failed to check for a standalone CLI', error );
return false;
}
}

async autoInstallIfNeeded(): Promise< void > {
const userData = await loadUserData();
if ( userData.cliUserUninstalled ) {
Expand Down
1 change: 1 addition & 0 deletions apps/studio/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ const api: IpcApi = {
startRemoteSessionDaemon: () => ipcRendererInvoke( 'startRemoteSessionDaemon' ),
stopRemoteSessionDaemon: () => ipcRendererInvoke( 'stopRemoteSessionDaemon' ),
isStudioCliInstalled: () => ipcRendererInvoke( 'isStudioCliInstalled' ),
isStudioCliExternallyManaged: () => ipcRendererInvoke( 'isStudioCliExternallyManaged' ),
installStudioCli: () => ipcRendererInvoke( 'installStudioCli' ),
uninstallStudioCli: () => ipcRendererInvoke( 'uninstallStudioCli' ),
getAgentInstructionsStatus: ( siteId ) =>
Expand Down
6 changes: 6 additions & 0 deletions apps/ui/src/components/settings-view/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ vi.mock( './skills-panel', () => ( {
SkillsPanel: () => null,
} ) );

vi.mock( './studio-cli-section', () => ( {
StudioCliSection: () => null,
} ) );

vi.mock( '@/data/queries/use-auth-user', () => ( {
useAuthUser: () => ( { data: null, isLoading: false } ),
useLogin: () => ( { mutate: vi.fn(), isPending: false } ),
Expand Down Expand Up @@ -155,6 +159,8 @@ describe( 'SettingsView', () => {
quitSitesBehavior: undefined,
locale: 'en',
defaultSiteDirectory: '/Users/example/Studio',
studioCliInstalled: false,
studioCliExternallyManaged: false,
},
isLoading: false,
} as never );
Expand Down
2 changes: 2 additions & 0 deletions apps/ui/src/components/settings-view/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { KeyboardPanel } from './keyboard-panel';
import { McpPanel } from './mcp-panel';
import { UNSET, toPreferencesFormData, toPreferencesPatch } from './preferences';
import { SkillsPanel } from './skills-panel';
import { StudioCliSection } from './studio-cli-section';
import { StudioCodePanel } from './studio-code-panel';
import styles from './style.module.css';
import type { PreferencesFormData } from './preferences';
Expand Down Expand Up @@ -314,6 +315,7 @@ function PreferencesPanel( {
</PreferenceRow>
</section>
<AccountSection />
<StudioCliSection />
<StudioExperienceSection />
</div>
);
Expand Down
2 changes: 2 additions & 0 deletions apps/ui/src/components/settings-view/preferences.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ const SAVED_PREFERENCES: UserPreferences = {
quitSitesBehavior: 'stop',
locale: 'en',
defaultSiteDirectory: '/Users/example/Studio',
studioCliInstalled: false,
studioCliExternallyManaged: false,
};

describe( 'settings preference helpers', () => {
Expand Down
121 changes: 121 additions & 0 deletions apps/ui/src/components/settings-view/studio-cli-section.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import '@testing-library/jest-dom/vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { Tooltip } from '@wordpress/ui';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAppGlobals } from '@/data/queries/use-app-globals';
import { useSaveUserPreferences, useUserPreferences } from '@/data/queries/use-user-preferences';
import { StudioCliSection } from './studio-cli-section';

vi.mock( '@/components/learn-more', () => ( {
LearnMoreLink: () => null,
} ) );

vi.mock( '@/data/queries/use-app-globals', () => ( {
useAppGlobals: vi.fn(),
} ) );

vi.mock( '@/data/queries/use-user-preferences', () => ( {
useSaveUserPreferences: vi.fn(),
useUserPreferences: vi.fn(),
} ) );

const useAppGlobalsMock = vi.mocked( useAppGlobals );
const useSaveUserPreferencesMock = vi.mocked( useSaveUserPreferences );
const useUserPreferencesMock = vi.mocked( useUserPreferences );

describe( 'StudioCliSection', () => {
const mutate = vi.fn();

beforeEach( () => {
vi.clearAllMocks();

useAppGlobalsMock.mockReturnValue( {
data: { platform: 'darwin', isWindowsStore: false },
} as never );
useSaveUserPreferencesMock.mockReturnValue( {
mutate,
isPending: false,
isError: false,
} as never );
useUserPreferencesMock.mockReturnValue( {
data: { studioCliInstalled: false, studioCliExternallyManaged: false },
} as never );
} );

it( 'saves as soon as the toggle is flipped', () => {
render( <StudioCliSection /> );

const toggle = screen.getByRole( 'checkbox', { name: 'Studio CLI for terminal' } );
expect( toggle ).not.toBeChecked();

fireEvent.click( toggle );

expect( mutate ).toHaveBeenCalledWith( { studioCliInstalled: true }, expect.any( Object ) );
expect( toggle ).toBeChecked();
} );

it( 'reverts the toggle to the saved state when the install fails', () => {
mutate.mockImplementation( ( _patch, options ) => options?.onSettled?.() );

render( <StudioCliSection /> );

const toggle = screen.getByRole( 'checkbox', { name: 'Studio CLI for terminal' } );
fireEvent.click( toggle );

expect( toggle ).not.toBeChecked();
} );

it( 'surfaces an install/uninstall error inline', () => {
useSaveUserPreferencesMock.mockReturnValue( {
mutate,
isPending: false,
isError: true,
} as never );

render( <StudioCliSection /> );

expect(
screen.getByText( 'An error occurred while updating the Studio CLI. Please try again.' )
).toBeInTheDocument();
} );

it( 'disables the toggle for a standalone (externally managed) CLI', () => {
useUserPreferencesMock.mockReturnValue( {
data: { studioCliInstalled: true, studioCliExternallyManaged: true },
} as never );

render(
<Tooltip.Provider>
<StudioCliSection />
</Tooltip.Provider>
);

const toggle = screen.getByRole( 'checkbox', { name: 'Studio CLI for terminal' } );
expect( toggle ).toBeChecked();
expect( toggle ).toBeDisabled();

fireEvent.click( toggle );

expect( mutate ).not.toHaveBeenCalled();
} );

it( 'is hidden in the browser', () => {
useAppGlobalsMock.mockReturnValue( {
data: { platform: 'browser', isWindowsStore: false },
} as never );

const { container } = render( <StudioCliSection /> );

expect( container ).toBeEmptyDOMElement();
} );

it( 'is hidden in Windows Store builds', () => {
useAppGlobalsMock.mockReturnValue( {
data: { platform: 'win32', isWindowsStore: true },
} as never );

const { container } = render( <StudioCliSection /> );

expect( container ).toBeEmptyDOMElement();
} );
} );
88 changes: 88 additions & 0 deletions apps/ui/src/components/settings-view/studio-cli-section.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { FormToggle } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { Tooltip } from '@wordpress/ui';
import { clsx } from 'clsx';
import { useState } from 'react';
import { LearnMoreLink } from '@/components/learn-more';
import { useAppGlobals } from '@/data/queries/use-app-globals';
import { useSaveUserPreferences, useUserPreferences } from '@/data/queries/use-user-preferences';
import styles from './style.module.css';

export function StudioCliSection() {
const { data: appGlobals } = useAppGlobals();
const { data: saved } = useUserPreferences();
const savePreferences = useSaveUserPreferences();
// Optimistic toggle state while an install/uninstall is in flight. Cleared
// on settle: on success the query cache already holds the new value, on
// failure the toggle falls back to the saved state.
const [ pending, setPending ] = useState< boolean | null >( null );

// The CLI installer only exists in the desktop app, and Windows Store
// builds can't write to PATH.
if ( ! appGlobals || appGlobals.platform === 'browser' || appGlobals.isWindowsStore || ! saved ) {
return null;
}

// A standalone (curl) install is never touched by the app, so the toggle
// is read-only with a tooltip pointing at `studio uninstall`.
const externallyManaged = saved.studioCliExternallyManaged;
const checked = pending ?? saved.studioCliInstalled;

const handleChange = ( studioCliInstalled: boolean ) => {
if ( externallyManaged ) {
return;
}
setPending( studioCliInstalled );
savePreferences.mutate( { studioCliInstalled }, { onSettled: () => setPending( null ) } );
};

const toggle = (
<FormToggle
id="studio-cli-toggle"
aria-label={ __( 'Studio CLI for terminal' ) }
checked={ checked }
disabled={ savePreferences.isPending || externallyManaged }
onChange={ ( event ) => handleChange( event.target.checked ) }
/>
);

return (
<section className={ styles.preferenceSectionGroup }>
<div className={ styles.cliHeader }>
<h2 className={ clsx( styles.preferenceSectionHeading, styles.cliHeading ) }>
{ __( 'Studio CLI' ) }
</h2>
{ externallyManaged ? (
// The default open delay reads as unresponsive on a disabled
// control, so this tooltip gets its own faster provider.
<Tooltip.Provider delay={ 200 }>
<Tooltip.Root>
<Tooltip.Trigger
render={ <span className={ styles.cliToggleTrigger }>{ toggle }</span> }
/>
<Tooltip.Popup
className={ styles.cliTooltip }
positioner={ <Tooltip.Positioner side="top" /> }
>
{ __(
'This studio command was installed with the standalone CLI installer, so Studio can’t manage it. Run studio uninstall in a terminal to remove it.'
) }
</Tooltip.Popup>
</Tooltip.Root>
</Tooltip.Provider>
) : (
toggle
) }
</div>
{ savePreferences.isError ? (
<div className={ styles.errorMessage }>
{ __( 'An error occurred while updating the Studio CLI. Please try again.' ) }
</div>
) : null }
<p className={ styles.cliDescription }>
{ __( 'Use the studio command in any terminal to manage sites and run WP-CLI.' ) }{ ' ' }
<LearnMoreLink docsLinksKey="docsCli" />
</p>
</section>
);
}
60 changes: 60 additions & 0 deletions apps/ui/src/components/settings-view/style.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,66 @@
fill: currentColor;
}

.cliHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--wpds-dimension-padding-xl);
}

.cliHeading {
margin-block-end: 0;
}

.cliHeader :global(.components-form-toggle .components-form-toggle__track) {
background-color: var(--wpds-color-bg-track-neutral-weak);
border-color: var(--wpds-color-stroke-interactive-neutral);
}

.cliHeader :global(.components-form-toggle .components-form-toggle__thumb) {
background-color: var(--wpds-color-bg-thumb-neutral-weak);
}

.cliHeader :global(.components-form-toggle.is-checked .components-form-toggle__track) {
background-color: var(--wpds-color-bg-interactive-brand-strong);
border-color: var(--wpds-color-stroke-interactive-brand);
}

.cliHeader :global(.components-form-toggle.is-checked .components-form-toggle__thumb) {
background-color: var(--wpds-color-fg-interactive-brand-strong);
}

.cliHeader
:global(.components-form-toggle .components-form-toggle__input:focus
+ .components-form-toggle__track) {
box-shadow:
0 0 0 var(--wpds-border-width-focus) var(--wpds-color-bg-surface-neutral),
0 0 0 calc(2 * var(--wpds-border-width-focus)) var(--wpds-color-stroke-focus-brand);
}

.cliToggleTrigger {
display: inline-flex;
}

/* Disabled form controls swallow hover events, which would keep the wrapper's
tooltip from ever opening. */
.cliToggleTrigger :global(input:disabled) {
pointer-events: none;
}

.cliTooltip {
max-inline-size: 280px;
line-height: var(--wpds-typography-line-height-sm);
}

.cliDescription {
max-inline-size: 460px;
margin: 0;
color: var(--wpds-color-fg-content-neutral-weak);
font-size: var(--wpds-typography-font-size-sm);
line-height: var(--wpds-typography-line-height-sm);
}

.shortcutList {
list-style: none;
margin: 0;
Expand Down
Loading