diff --git a/apps/ui/src/components/open-in-menu/index.test.tsx b/apps/ui/src/components/open-in-menu/index.test.tsx
index 1e911df4b5..3d3a6c327c 100644
--- a/apps/ui/src/components/open-in-menu/index.test.tsx
+++ b/apps/ui/src/components/open-in-menu/index.test.tsx
@@ -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';
@@ -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 } );
@@ -127,6 +137,10 @@ describe( 'OpenInMenu', () => {
} );
} );
+ afterEach( () => {
+ vi.restoreAllMocks();
+ } );
+
it( 'routes each destination through the connector', async () => {
renderMenu( { running: true } );
@@ -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 } );
diff --git a/apps/ui/src/components/open-in-menu/use-open-in-destinations.tsx b/apps/ui/src/components/open-in-menu/use-open-in-destinations.tsx
index 8e0dea7845..3bbeaaf9ce 100644
--- a/apps/ui/src/components/open-in-menu/use-open-in-destinations.tsx
+++ b/apps/ui/src/components/open-in-menu/use-open-in-destinations.tsx
@@ -1,3 +1,4 @@
+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';
@@ -5,6 +6,7 @@ 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';
@@ -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.' ) );
} );
},
},
diff --git a/apps/ui/src/components/site-overview-view/index.test.tsx b/apps/ui/src/components/site-overview-view/index.test.tsx
index dfb978a08b..2efbfc757f 100644
--- a/apps/ui/src/components/site-overview-view/index.test.tsx
+++ b/apps/ui/src/components/site-overview-view/index.test.tsx
@@ -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() );
@@ -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(),
@@ -118,12 +128,16 @@ 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();
@@ -131,6 +145,18 @@ describe( 'SiteOverviewView', () => {
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 );
@@ -150,7 +176,8 @@ describe( 'SiteOverviewView', () => {
} ) ),
} );
- useConnectorMock.mockReturnValue( { openSiteUrl, trackEvent } );
+ useConnectorMock.mockReturnValue( connectorStub() );
+ useUserPreferencesMock.mockReturnValue( { data: preferencesStub( 'vscode' ) } );
useAgenticFeaturesMock.mockReturnValue( {
enabled: true,
chatEnabled: true,
@@ -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();
} );
@@ -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 () => {
diff --git a/apps/ui/src/components/site-overview-view/index.tsx b/apps/ui/src/components/site-overview-view/index.tsx
index da19069abc..3634bfd3f0 100644
--- a/apps/ui/src/components/site-overview-view/index.tsx
+++ b/apps/ui/src/components/site-overview-view/index.tsx
@@ -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';
@@ -52,6 +56,7 @@ interface OverviewButtonProps {
loading?: boolean;
loadingAnnouncement?: string;
className?: string;
+ brandIcon?: boolean;
}
function OverviewHeader( {
@@ -91,6 +96,7 @@ function OverviewButton( {
loading,
loadingAnnouncement,
className,
+ brandIcon,
}: OverviewButtonProps ) {
return (
);
}
@@ -113,12 +128,60 @@ function OverviewButton( {
function ButtonSection( { title, children }: { title: string; children: ReactNode } ) {
return (
- { title }
+ { title }
{ children }
);
}
+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 (
+
+ { apps.map( ( destination ) => (
+ }
+ label={ destination.label }
+ disabled={ destination.disabled }
+ onClick={ destination.open }
+ />
+ ) ) }
+ }
+ 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 );
+ } }
+ />
+
+ );
+}
+
export function SiteOverviewView( {
siteId,
activeTab,
@@ -314,6 +377,10 @@ function SiteOverviewBody( {
/>
+ { connector.capabilities.openInOS && (
+
+ ) }
+
{ managementActions.map( ( action ) => (
);
+export const databaseLogo = (
+
+);
+
export const editorLogos: Partial< Record< SupportedEditor, ReactElement > > = {
antigravity: antigravityLogo,
vscode: vscodeLogo,