Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions apps/ui/src/components/open-in-menu/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import '@testing-library/jest-dom/vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { captureException } from '@studio/common/lib/error-reporting';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { toast } from '@/data/app-messages';
import { useConnector } from '@/data/core';
import { useStartSite } from '@/data/queries/use-sites';
import { useUserPreferences } from '@/data/queries/use-user-preferences';
Expand Down Expand Up @@ -80,6 +82,14 @@ vi.mock( '@/data/queries/use-user-preferences', () => ( {
useUserPreferences: vi.fn(),
} ) );

vi.mock( '@studio/common/lib/error-reporting', () => ( {
captureException: vi.fn(),
} ) );

vi.mock( '@/data/app-messages', () => ( {
toast: { error: vi.fn() },
} ) );

const useConnectorMock = vi.mocked( useConnector, { partial: true } );
const useStartSiteMock = vi.mocked( useStartSite, { partial: true } );
const useUserPreferencesMock = vi.mocked( useUserPreferences, { partial: true } );
Expand Down Expand Up @@ -127,6 +137,10 @@ describe( 'OpenInMenu', () => {
} );
} );

afterEach( () => {
vi.restoreAllMocks();
} );

it( 'routes each destination through the connector', async () => {
renderMenu( { running: true } );

Expand All @@ -144,6 +158,19 @@ describe( 'OpenInMenu', () => {
expect( openSiteInTerminal ).toHaveBeenCalledWith( 'site-1' );
} );

it( 'reports terminal failures and tells the user', async () => {
const error = new Error( 'Terminal unavailable' );
openSiteInTerminal.mockRejectedValueOnce( error );
const consoleErrorMock = vi.spyOn( console, 'error' ).mockImplementation( () => undefined );
renderMenu( { running: true } );

fireEvent.click( destination( 'Terminal' ) );

await waitFor( () => expect( captureException ).toHaveBeenCalledWith( error ) );
expect( consoleErrorMock ).toHaveBeenCalledWith( 'Failed to open site in terminal:', error );
expect( toast.error ).toHaveBeenCalledWith( 'Could not open the terminal.' );
} );

it( 'records Tracks events for browser and folder only (editor and terminal emit in Main)', () => {
renderMenu( { running: true } );

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { captureException } from '@studio/common/lib/error-reporting';
import { TRACKS_EVENTS } from '@studio/common/lib/record-tracks-event';
import { supportedEditorConfig } from '@studio/common/lib/user-settings/editor';
import { terminalConfig } from '@studio/common/lib/user-settings/terminal';
import { useNavigate } from '@tanstack/react-router';
import { __ } from '@wordpress/i18n';
import { code, external } from '@wordpress/icons';
import { getPreviewRealm, getRealmOpenEvent } from '@/components/site-preview/address-bar';
import { toast } from '@/data/app-messages';
import { useConnector } from '@/data/core';
import { useUserPreferences } from '@/data/queries/use-user-preferences';
import { editorLogos, finderLogo, folderLogo, terminalLogo, terminalLogos } from '@/lib/logos';
Expand Down Expand Up @@ -127,6 +129,8 @@ export function useOpenInDestinations(
onOpen?.( 'terminal' );
void connector.openSiteInTerminal( site.id ).catch( ( error ) => {
console.error( 'Failed to open site in terminal:', error );
captureException( error );
toast.error( __( 'Could not open the terminal.' ) );
} );
},
},
Expand Down
74 changes: 70 additions & 4 deletions apps/ui/src/components/site-overview-view/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,17 @@ import {
useUpdateSite,
useXdebugEnabledSite,
} from '@/data/queries/use-sites';
import { useUserPreferences } from '@/data/queries/use-user-preferences';
import { useWordPressVersions, useWpVersion } from '@/data/queries/use-wordpress-versions';
import { useOffline } from '@/hooks/use-offline';
import styles from './style.module.css';
import { SiteOverviewView } from './index';
import type { SiteDetails } from '@/data/core';
import type {
ConnectorCapabilities,
SiteDetails,
SupportedEditor,
UserPreferences,
} from '@/data/core';

const navigateMock = vi.fn();
const siteDropdownMock = vi.hoisted( () => vi.fn() );
Expand Down Expand Up @@ -88,6 +94,10 @@ vi.mock( '@/data/queries/use-sites', () => ( {
useXdebugEnabledSite: vi.fn(),
} ) );

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

vi.mock( '@/data/queries/use-wordpress-versions', () => ( {
useWordPressVersions: vi.fn(),
useWpVersion: vi.fn(),
Expand Down Expand Up @@ -118,19 +128,35 @@ const useSitesMock = vi.mocked( useSites, { partial: true } );
const useStartSiteMock = vi.mocked( useStartSite, { partial: true } );
const useUpdateSiteMock = vi.mocked( useUpdateSite, { partial: true } );
const useOfflineMock = vi.mocked( useOffline );
const useUserPreferencesMock = vi.mocked( useUserPreferences, { partial: true } );
const useWordPressVersionsMock = vi.mocked( useWordPressVersions, { partial: true } );
const useWpVersionMock = vi.mocked( useWpVersion, { partial: true } );
const useXdebugEnabledSiteMock = vi.mocked( useXdebugEnabledSite, { partial: true } );

describe( 'SiteOverviewView', () => {
const openSiteUrl = vi.fn().mockResolvedValue( undefined );
const openSiteFolder = vi.fn().mockResolvedValue( undefined );
const openSiteInEditor = vi.fn().mockResolvedValue( undefined );
const openSiteInTerminal = vi.fn().mockResolvedValue( undefined );
const trackEvent = vi.fn().mockResolvedValue( undefined );
const startSite = vi.fn().mockResolvedValue( undefined );
const copySite = vi.fn();
const exportFullSite = vi.fn();
const exportDatabase = vi.fn();
const onTabChange = vi.fn();

const connectorStub = ( openInOS = true ) => ( {
openSiteUrl,
openSiteFolder,
openSiteInEditor,
openSiteInTerminal,
trackEvent,
capabilities: { openInOS } as ConnectorCapabilities,
} );

const preferencesStub = ( editor: SupportedEditor | null ) =>
( { editor, terminal: 'terminal' } ) as UserPreferences;

beforeEach( () => {
vi.clearAllMocks();
useSidebarCollapsedMock.mockReturnValue( false );
Expand All @@ -150,7 +176,8 @@ describe( 'SiteOverviewView', () => {
} ) ),
} );

useConnectorMock.mockReturnValue( { openSiteUrl, trackEvent } );
useConnectorMock.mockReturnValue( connectorStub() );
useUserPreferencesMock.mockReturnValue( { data: preferencesStub( 'vscode' ) } );
useAgenticFeaturesMock.mockReturnValue( {
enabled: true,
chatEnabled: true,
Expand Down Expand Up @@ -212,8 +239,8 @@ describe( 'SiteOverviewView', () => {
expect( screen.getByText( 'Media Library' ) ).toBeVisible();
expect( screen.queryByText( 'Customizer' ) ).not.toBeInTheDocument();
expect( screen.getByText( 'Duplicate' ) ).toBeVisible();
expect( screen.getByText( 'Export' ) ).toBeVisible();
expect( screen.getByText( 'Export DB' ) ).toBeVisible();
expect( screen.getByText( 'Export entire site' ) ).toBeVisible();
expect( screen.getByText( 'Export database' ) ).toBeVisible();
expect( screen.getByText( 'Delete' ) ).toBeVisible();
expect( screen.queryByDisplayValue( 'Demo Site' ) ).not.toBeInTheDocument();
} );
Expand Down Expand Up @@ -477,6 +504,45 @@ describe( 'SiteOverviewView', () => {
expect( screen.queryByText( 'Site Editor' ) ).not.toBeInTheDocument();
} );

it( 'offers the configured apps and phpMyAdmin under Open in…', () => {
renderView();

expect( screen.getByRole( 'heading', { name: 'Open in…' } ) ).toBeVisible();
expect( screen.getByText( 'Finder' ) ).toBeVisible();
expect( screen.getByText( 'Visual Studio Code' ) ).toBeVisible();
expect( screen.getByText( 'Terminal' ) ).toBeVisible();
expect( screen.queryByText( 'Browser' ) ).not.toBeInTheDocument();

fireEvent.click( screen.getByText( 'Finder' ).closest( 'button' )! );
expect( openSiteFolder ).toHaveBeenCalledWith( 'site-1' );

fireEvent.click( screen.getByText( 'phpMyAdmin' ).closest( 'button' )! );
expect( openSiteUrl ).toHaveBeenCalledWith(
'site-1',
'/phpmyadmin/index.php?route=/database/structure&db=wordpress'
);
expect( trackEvent ).toHaveBeenCalledWith( 'studio_site_open_phpmyadmin', {
browser: 'internal',
} );
} );

it( 'hides the editor shortcut until an editor is configured', () => {
useUserPreferencesMock.mockReturnValue( { data: preferencesStub( null ) } );

renderView();

expect( screen.queryByText( 'Visual Studio Code' ) ).not.toBeInTheDocument();
expect( screen.getByText( 'Finder' ) ).toBeVisible();
} );

it( 'drops the Open in… section on hosts that cannot open local apps', () => {
useConnectorMock.mockReturnValue( connectorStub( false ) );

renderView();

expect( screen.queryByRole( 'heading', { name: 'Open in…' } ) ).not.toBeInTheDocument();
} );

// Rendered without a SessionUIProvider, so the open-site-url hook takes
// its browser fallback path; inside the app these open the preview panel.
it( 'routes shortcuts through the connector', async () => {
Expand Down
73 changes: 70 additions & 3 deletions apps/ui/src/components/site-overview-view/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,21 @@ import { useState } from 'react';
import { AgenticSigninBanner } from '@/components/agentic-signin-banner';
import { DeleteSiteDialog } from '@/components/delete-site-dialog';
import { OfflineBanner } from '@/components/offline-banner';
import { useOpenInDestinations } from '@/components/open-in-menu/use-open-in-destinations';
import { PreviewToggleButton } from '@/components/preview-toggle-button';
import { ProgressiveBlur } from '@/components/progressive-blur';
import { SiteDropdown } from '@/components/site-dropdown';
import { DATABASE_HOME_PATH } from '@/components/site-preview/address-bar';
import { isSiteSettingsTab, SiteSettingsForm } from '@/components/site-settings-view';
import * as Tabs from '@/components/tabs';
import { useConnector } from '@/data/core';
import { useIsSiteStarting, useIsSiteStopping, useSites } from '@/data/queries/use-sites';
import { useUserPreferences } from '@/data/queries/use-user-preferences';
import { useOpenSiteUrl } from '@/hooks/use-open-site-url';
import { useSidebarCollapsed } from '@/hooks/use-sidebar-collapsed';
import { useSiteManagementActions } from '@/hooks/use-site-management-actions';
import { useTrafficLightSpace } from '@/hooks/use-traffic-light-space';
import { databaseLogo } from '@/lib/logos';
import styles from './style.module.css';
import type { SiteSettingsTabId } from '@/components/site-settings-view';
import type { SiteDetails } from '@/data/core';
Expand All @@ -52,6 +56,7 @@ interface OverviewButtonProps {
loading?: boolean;
loadingAnnouncement?: string;
className?: string;
brandIcon?: boolean;
}

function OverviewHeader( {
Expand Down Expand Up @@ -91,6 +96,7 @@ function OverviewButton( {
loading,
loadingAnnouncement,
className,
brandIcon,
}: OverviewButtonProps ) {
return (
<Button
Expand All @@ -102,23 +108,80 @@ function OverviewButton( {
loadingAnnouncement={ loadingAnnouncement }
onClick={ onClick }
>
<span className={ styles.overviewButtonIcon } aria-hidden="true">
<span
className={
brandIcon
? `${ styles.overviewButtonIcon } ${ styles.brandIcon }`
: styles.overviewButtonIcon
}
aria-hidden="true"
>
{ icon }
</span>
<span className={ styles.overviewButtonLabel }>{ label }</span>
<span className={ styles.overviewButtonLabel } title={ label }>
{ label }
</span>
</Button>
);
}

function ButtonSection( { title, children }: { title: string; children: ReactNode } ) {
return (
<section className={ styles.buttonSection }>
<h2>{ title }</h2>
<h2 className={ styles.columnHeading }>{ title }</h2>
<div className={ styles.buttonGrid }>{ children }</div>
</section>
);
}

function OpenInSection( {
site,
busy,
openSiteUrl,
}: {
site: SiteDetails;
busy: boolean;
openSiteUrl: ( url: string ) => Promise< void >;
} ) {
const connector = useConnector();
const { data: preferences } = useUserPreferences();
const destinations = useOpenInDestinations( site, '/' );
const editorConfigured = Boolean( preferences?.editor );

const apps = destinations.filter(
( destination ) =>
destination.id !== 'browser' && ( destination.id !== 'editor' || editorConfigured )
);

return (
<ButtonSection title={ __( 'Open in…' ) }>
{ apps.map( ( destination ) => (
<OverviewButton
key={ destination.id }
brandIcon
icon={ <Icon icon={ destination.logo } size={ 18 } /> }
label={ destination.label }
disabled={ destination.disabled }
onClick={ destination.open }
/>
) ) }
<OverviewButton
brandIcon
icon={ <Icon icon={ databaseLogo } size={ 18 } /> }
label={ __( 'phpMyAdmin' ) }
disabled={ busy }
onClick={ () => {
// Opens in the in-app preview panel, not the OS browser.
void connector.trackEvent( TRACKS_EVENTS.SITE_OPEN_PHPMYADMIN, {
browser: 'internal',
} );
void openSiteUrl( DATABASE_HOME_PATH );
} }
/>
</ButtonSection>
);
}

export function SiteOverviewView( {
siteId,
activeTab,
Expand Down Expand Up @@ -314,6 +377,10 @@ function SiteOverviewBody( {
/>
</ButtonSection>

{ connector.capabilities.openInOS && (
<OpenInSection site={ site } busy={ busy } openSiteUrl={ openSiteUrl } />
) }

<ButtonSection title={ __( 'Manage' ) }>
{ managementActions.map( ( action ) => (
<OverviewButton
Expand Down
Loading