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

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/studio/src/components/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { SiteContentTabs } from 'src/components/site-content-tabs';
import TopBar from 'src/components/top-bar';
import { useListenDeepLinkConnection } from 'src/hooks/sync-sites/use-listen-deep-link-connection';
import { useAuth } from 'src/hooks/use-auth';
import { useIpcListener } from 'src/hooks/use-ipc-listener';
import { useLocalizationSupport } from 'src/hooks/use-localization-support';
import { useSidebarResize } from 'src/hooks/use-sidebar-resize';
import { useSidebarVisibility } from 'src/hooks/use-sidebar-visibility';
Expand Down Expand Up @@ -42,12 +43,19 @@ export default function App() {
const { showWhatsNew, closeWhatsNew } = useWhatsNew();
const { sites: localSites, loadingSites } = useSiteDetails();
const isEmpty = ! loadingSites && ! localSites.length;
const canToggleSidebar = ! needsOnboarding && ! isEmpty;
const shouldShowWhatsNew = showWhatsNew && ! isEmpty;
const { client } = useAuth();
const dispatch = useAppDispatch();
const isWapuuWorldOpen = useRootSelector( selectIsWapuuWorldOpen );
const activateWapuuWorld = useCallback( () => dispatch( openWapuuWorld() ), [ dispatch ] );
const handleToggleSidebarShortcut = useCallback( () => {
if ( canToggleSidebar ) {
toggleSidebar();
}
}, [ canToggleSidebar, toggleSidebar ] );
useKonamiCode( activateWapuuWorld );
useIpcListener( 'toggle-sidebar', handleToggleSidebarShortcut );

// Initialize sync states from in-progress server operations
useEffect( () => {
Expand Down
1 change: 1 addition & 0 deletions apps/studio/src/ipc-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export interface IpcEvents {
},
];
'test-render-failure': [ void ];
'toggle-sidebar': [ void ];
'toggle-site-preview': [ void ];
'theme-details-loading': [ { id: string } ];
'theme-details-loaded': [ { id: string; details: StartedSiteDetails[ 'themeDetails' ] } ];
Expand Down
29 changes: 29 additions & 0 deletions apps/studio/src/main-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,28 @@ function setupDevTools( mainWindow: BrowserWindow | null, devToolsOpen?: boolean
}
}

export function isToggleSidebarShortcut(
input: Electron.Input,
platform: NodeJS.Platform = process.platform
): boolean {
if (
input.type !== 'keyDown' ||
input.isAutoRepeat ||
input.isComposing ||
input.shift ||
input.alt ||
input.key.toLowerCase() !== 'b'
) {
return false;
}

if ( platform === 'darwin' ) {
return input.meta && ! input.control;
}

return input.control && ! input.meta;
}

function initializePortFinder( sites: SiteDetails[] ) {
sites.forEach( ( site ) => {
if ( site.port ) {
Expand Down Expand Up @@ -176,6 +198,13 @@ export async function createMainWindow(): Promise< BrowserWindow > {

mainWindow = new BrowserWindow( windowOptions );

mainWindow.webContents.on( 'before-input-event', ( event, input ) => {
if ( isToggleSidebarShortcut( input ) ) {
event.preventDefault();
sendIpcEventToRendererWithWindow( mainWindow, 'toggle-sidebar' );
}
} );

// Restore fullscreen state if it was saved
if ( savedBounds?.isFullScreen ) {
mainWindow.setFullScreen( true );
Expand Down
8 changes: 8 additions & 0 deletions apps/studio/src/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,14 @@ async function getAppMenu(
submenu: [
{ label: __( 'Show Tab Bar' ), role: 'toggleTabBar' },
{ label: __( 'Show All Tabs' ), role: 'showAllTabs' },
{
label: __( 'Toggle Sidebar' ),
accelerator: 'CommandOrControl+B',
enabled: ! needsOnboarding,
click: () => {
void sendIpcEventToRenderer( 'toggle-sidebar' );
},
},
{
label: __( 'Toggle Site Preview' ),
accelerator: 'CommandOrControl+Shift+B',
Expand Down
75 changes: 74 additions & 1 deletion apps/studio/src/tests/main-window.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import { readFile } from 'atomically';
import { vol } from 'memfs';
import { vi } from 'vitest';
import { sendIpcEventToRendererWithWindow } from 'src/ipc-utils';
import { createMainWindow, getMainWindow, __resetMainWindow } from 'src/main-window';
import {
createMainWindow,
getMainWindow,
isToggleSidebarShortcut,
__resetMainWindow,
} from 'src/main-window';

vi.mock( 'fs' );
vi.mock( 'src/ipc-utils' );
Expand All @@ -17,6 +22,7 @@ vi.mock( 'src/lib/app-globals', () => ( {

// Create a simpler mock that tracks event handlers
const mockEventHandlers = new Map< string, ( ( ...args: any[] ) => void )[] >();
const mockWebContentsEventHandlers = new Map< string, ( ( ...args: any[] ) => void )[] >();

vi.mock( 'electron', () => {
class MockBrowserWindow {
Expand All @@ -42,6 +48,11 @@ vi.mock( 'electron', () => {
isDestroyed: vi.fn().mockReturnValue( false ),
send: vi.fn(),
on: vi.fn().mockImplementation( ( event: string, handler: ( ...args: any[] ) => void ) => {
if ( ! mockWebContentsEventHandlers.has( event ) ) {
mockWebContentsEventHandlers.set( event, [] );
}
mockWebContentsEventHandlers.get( event )!.push( handler );

if ( event === 'did-finish-load' ) {
// Call handler immediately to resolve window creation
setImmediate( handler );
Expand Down Expand Up @@ -90,6 +101,7 @@ beforeEach( () => {
delete process.env.ENABLE_AGENTIC_UI;
delete process.env.ELECTRON_UI_RENDERER_URL;
delete process.env.ELECTRON_RENDERER_URL;
mockWebContentsEventHandlers.clear();
} );

describe( 'getMainWindow', () => {
Expand Down Expand Up @@ -166,6 +178,67 @@ describe( 'renderer selection', () => {
} );
} );

describe( 'sidebar shortcut', () => {
afterEach( () => {
__resetMainWindow();
} );

it( 'matches command+b on macOS and control+b on other platforms', () => {
const input = {
type: 'keyDown',
key: 'b',
code: 'KeyB',
isAutoRepeat: false,
isComposing: false,
shift: false,
control: false,
alt: false,
meta: false,
location: 0,
modifiers: [],
} as Electron.Input;

expect( isToggleSidebarShortcut( { ...input, meta: true }, 'darwin' ) ).toBe( true );
expect( isToggleSidebarShortcut( { ...input, control: true }, 'win32' ) ).toBe( true );
expect( isToggleSidebarShortcut( { ...input, control: true }, 'linux' ) ).toBe( true );
expect( isToggleSidebarShortcut( { ...input, control: true }, 'darwin' ) ).toBe( false );
expect( isToggleSidebarShortcut( { ...input, meta: true }, 'win32' ) ).toBe( false );
expect( isToggleSidebarShortcut( { ...input, meta: true, shift: true }, 'darwin' ) ).toBe(
false
);
expect(
isToggleSidebarShortcut( { ...input, meta: true, isAutoRepeat: true }, 'darwin' )
).toBe( false );
} );

it( 'sends a renderer toggle event from the main window shortcut handler', async () => {
const createdWindow = await createMainWindow();
const handlers = mockWebContentsEventHandlers.get( 'before-input-event' );
const event = { preventDefault: vi.fn() };

expect( handlers ).toBeDefined();
handlers![ 0 ]( event, {
type: 'keyDown',
key: 'b',
code: 'KeyB',
isAutoRepeat: false,
isComposing: false,
shift: false,
control: process.platform !== 'darwin',
alt: false,
meta: process.platform === 'darwin',
location: 0,
modifiers: [],
} as Electron.Input );

expect( event.preventDefault ).toHaveBeenCalled();
expect( sendIpcEventToRendererWithWindow ).toHaveBeenCalledWith(
createdWindow,
'toggle-sidebar'
);
} );
} );

describe( 'fullscreen events', () => {
let createdWindow: BrowserWindow;

Expand Down
77 changes: 77 additions & 0 deletions apps/ui/src/components/sidebar-layout/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { act, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useConnector } from '@/data/core';
import { SidebarLayout } from './index';
import type { ReactNode } from 'react';

vi.mock( '@/components/sidebar-header', () => ( {
SidebarHeader: ( { onToggleSidebar }: { onToggleSidebar: () => void } ) => (
<button onClick={ onToggleSidebar }>Hide sidebar</button>
),
} ) );

vi.mock( '@/components/site-list', () => ( {
SiteList: () => <nav aria-label="Sites" />,
} ) );

vi.mock( '@/components/user-menu', () => ( {
UserMenu: () => null,
} ) );

vi.mock( '@/data/core', () => ( {
useConnector: vi.fn(),
} ) );

vi.mock( '@/hooks/use-fullscreen', () => ( {
useFullscreen: () => false,
} ) );

vi.mock( '@wordpress/ui', async () => {
const actual = await vi.importActual< typeof import('@wordpress/ui') >( '@wordpress/ui' );
return {
...actual,
IconButton: ( {
label,
onClick,
}: {
label: string;
onClick: () => void;
children?: ReactNode;
} ) => <button onClick={ onClick }>{ label }</button>,
};
} );

const useConnectorMock = vi.mocked( useConnector, { partial: true } );

describe( 'SidebarLayout', () => {
let toggleSidebarListener: ( () => void ) | undefined;

beforeEach( () => {
vi.clearAllMocks();
toggleSidebarListener = undefined;
useConnectorMock.mockReturnValue( {
onToggleSidebar: vi.fn( ( listener ) => {
toggleSidebarListener = listener;
return vi.fn();
} ),
} );
} );

it( 'toggles the sidebar when the connector emits the shortcut command', () => {
render(
<SidebarLayout>
<div>Content</div>
</SidebarLayout>
);

expect( screen.queryByRole( 'button', { name: 'Show sidebar' } ) ).not.toBeInTheDocument();

act( () => toggleSidebarListener?.() );

expect( screen.getByRole( 'button', { name: 'Show sidebar' } ) ).toBeInTheDocument();

act( () => toggleSidebarListener?.() );

expect( screen.queryByRole( 'button', { name: 'Show sidebar' } ) ).not.toBeInTheDocument();
} );
} );
13 changes: 10 additions & 3 deletions apps/ui/src/components/sidebar-layout/index.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { __ } from '@wordpress/i18n';
import { IconButton } from '@wordpress/ui';
import { clsx } from 'clsx';
import { useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { ResizeHandle, ResizeOverlay } from '@/components/resize-handle';
import { SidebarHeader } from '@/components/sidebar-header';
import { SiteList } from '@/components/site-list';
import { UserMenu } from '@/components/user-menu';
import { useConnector } from '@/data/core';
import { useFullscreen } from '@/hooks/use-fullscreen';
import { useResizablePanel } from '@/hooks/use-resizable-panel';
import { SidebarCollapsedContext } from '@/hooks/use-sidebar-collapsed';
Expand All @@ -16,16 +17,22 @@ import type { CSSProperties, ReactNode } from 'react';

export function SidebarLayout( { children }: { children: ReactNode } ) {
const [ collapsed, setCollapsed ] = useState( false );
const connector = useConnector();
const isFullscreen = useFullscreen();
const sidebarResize = useResizablePanel( {
config: SIDEBAR_PANEL_CONFIG,
edge: 'right',
storageKey: SIDEBAR_PANEL_STORAGE_KEY,
} );
const toggleSidebar = useCallback( () => {
setCollapsed( ( value ) => ! value );
}, [] );
const sidebarStyle = collapsed
? undefined
: ( { '--sidebar-width': `${ sidebarResize.width }px` } as CSSProperties );

useEffect( () => connector.onToggleSidebar( toggleSidebar ), [ connector, toggleSidebar ] );

return (
<SidebarCollapsedContext.Provider value={ collapsed }>
<div className={ styles.root }>
Expand All @@ -37,7 +44,7 @@ export function SidebarLayout( { children }: { children: ReactNode } ) {
) }
style={ sidebarStyle }
>
<SidebarHeader onToggleSidebar={ () => setCollapsed( true ) } />
<SidebarHeader onToggleSidebar={ toggleSidebar } />
<SiteList />
<div className={ styles.sidebarFooter }>
<UserMenu />
Expand Down Expand Up @@ -69,7 +76,7 @@ export function SidebarLayout( { children }: { children: ReactNode } ) {
size="small"
icon={ drawerIcon }
label={ __( 'Show sidebar' ) }
onClick={ () => setCollapsed( false ) }
onClick={ toggleSidebar }
/>
</div>
) : null }
Expand Down
4 changes: 4 additions & 0 deletions apps/ui/src/data/core/connectors/hosted/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,5 +364,9 @@ export function createHostedConnector( { apiBaseUrl }: HostedConnectorOptions ):
// No application menu in a browser tab.
return () => {};
},
onToggleSidebar() {
// No application menu in a browser tab.
return () => {};
},
};
}
6 changes: 6 additions & 0 deletions apps/ui/src/data/core/connectors/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -686,5 +686,11 @@ export function createIpcConnector(): Connector {
const ipcListener = ( window as any ).ipcListener;
return ipcListener.subscribe( 'toggle-site-preview', () => listener() );
},

onToggleSidebar( listener ) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ipcListener = ( window as any ).ipcListener;
return ipcListener.subscribe( 'toggle-sidebar', () => listener() );
},
};
}
3 changes: 3 additions & 0 deletions apps/ui/src/data/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,9 @@ export interface Connector {
// Fires when the user activates "View > Toggle Site Preview" (⌘⇧B) in the
// application menu.
onToggleSitePreview( listener: () => void ): () => void;

// Fires when the user activates the sidebar toggle shortcut or menu command.
onToggleSidebar( listener: () => void ): () => void;
}

export type ColorScheme = 'system' | 'light' | 'dark';
Expand Down