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,