Skip to content

Commit 0181a9b

Browse files
authored
Add site context menu (#1796)
* This PR adds a context menu to sites in the sidebar
1 parent 651c642 commit 0181a9b

File tree

13 files changed

+901
-116
lines changed

13 files changed

+901
-116
lines changed

src/components/delete-site.tsx

Lines changed: 6 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,29 @@
1-
import * as Sentry from '@sentry/electron/renderer';
21
import { MenuItem } from '@wordpress/components';
3-
import { __, sprintf } from '@wordpress/i18n';
2+
import { __ } from '@wordpress/i18n';
43
import { useI18n } from '@wordpress/react-i18n';
4+
import { useDeleteSite } from 'src/hooks/use-delete-site';
55
import { useSiteDetails } from 'src/hooks/use-site-details';
6-
import { getIpcApi } from 'src/lib/get-ipc-api';
7-
8-
const MAX_LENGTH_SITE_TITLE = 35;
96

107
type DeleteSiteProps = {
118
onClose: () => void;
129
};
1310

1411
const DeleteSite = ( { onClose }: DeleteSiteProps ) => {
1512
const { __ } = useI18n();
16-
const { selectedSite, deleteSite, isDeleting } = useSiteDetails();
17-
18-
const handleDeleteSite = async () => {
19-
if ( ! selectedSite ) {
20-
return;
21-
}
22-
23-
const DELETE_BUTTON_INDEX = 0;
24-
const CANCEL_BUTTON_INDEX = 1;
25-
26-
const trimmedSiteTitle = getTrimmedSiteTitle( selectedSite.name );
27-
28-
const { response, checkboxChecked } = await getIpcApi().showMessageBox( {
29-
type: 'warning',
30-
message: sprintf( __( 'Delete %s' ), trimmedSiteTitle ),
31-
detail: __(
32-
'The site’s database will be lost. Including all posts, pages, comments, and media.'
33-
),
34-
buttons: [ __( 'Delete site' ), __( 'Cancel' ) ],
35-
cancelId: CANCEL_BUTTON_INDEX,
36-
checkboxLabel: __( 'Delete site files from my computer' ),
37-
checkboxChecked: true,
38-
} );
39-
40-
if ( response === DELETE_BUTTON_INDEX ) {
41-
try {
42-
await deleteSite( selectedSite.id, checkboxChecked );
43-
} catch ( error ) {
44-
getIpcApi().showErrorMessageBox( {
45-
title: __( 'Deletion failed' ),
46-
message: sprintf(
47-
__( "We couldn't delete the site '%s'. Please try again" ),
48-
trimmedSiteTitle
49-
),
50-
error,
51-
} );
52-
Sentry.captureException( error );
53-
}
54-
}
55-
};
56-
57-
const getTrimmedSiteTitle = ( name: string ) =>
58-
name.length > MAX_LENGTH_SITE_TITLE
59-
? `${ name.substring( 0, MAX_LENGTH_SITE_TITLE - 3 ) }…`
60-
: name;
13+
const { selectedSite, isDeleting } = useSiteDetails();
14+
const { handleDeleteSite } = useDeleteSite();
6115

6216
const isSiteDeletionDisabled = ! selectedSite || isDeleting;
6317

6418
return (
6519
<MenuItem
6620
aria-disabled={ isSiteDeletionDisabled }
6721
onClick={ () => {
68-
if ( isSiteDeletionDisabled ) {
22+
if ( isSiteDeletionDisabled || ! selectedSite ) {
6923
return;
7024
}
7125
onClose();
72-
void handleDeleteSite();
26+
void handleDeleteSite( selectedSite.id, selectedSite.name );
7327
} }
7428
isDestructive
7529
disabled={ isSiteDeletionDisabled }

src/components/site-menu.tsx

Lines changed: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
1+
import * as Sentry from '@sentry/electron/renderer';
12
import { speak } from '@wordpress/a11y';
23
import { Spinner } from '@wordpress/components';
34
import { __, sprintf } from '@wordpress/i18n';
45
import { useEffect } from 'react';
56
import { Tooltip } from 'src/components/tooltip';
67
import { useSyncSites } from 'src/hooks/sync-sites';
8+
import { useContentTabs } from 'src/hooks/use-content-tabs';
9+
import { useDeleteSite } from 'src/hooks/use-delete-site';
710
import { useImportExport } from 'src/hooks/use-import-export';
811
import { useSiteDetails } from 'src/hooks/use-site-details';
9-
import { isMac } from 'src/lib/app-globals';
12+
import { isMac, isWindows } from 'src/lib/app-globals';
1013
import { cx } from 'src/lib/cx';
14+
import { getIpcApi } from 'src/lib/get-ipc-api';
15+
import { supportedEditorConfig } from 'src/modules/user-settings/lib/editor';
16+
import { getTerminalName } from 'src/modules/user-settings/lib/terminal';
17+
import { useGetUserEditorQuery, useGetUserTerminalQuery } from 'src/stores/installed-apps-api';
1118

1219
interface SiteMenuProps {
1320
className?: string;
@@ -108,10 +115,21 @@ function ButtonToRun( { running, id, name }: Pick< SiteDetails, 'running' | 'id'
108115
);
109116
}
110117
function SiteItem( { site }: { site: SiteDetails } ) {
111-
const { selectedSite, setSelectedSiteId } = useSiteDetails();
118+
const {
119+
selectedSite,
120+
setSelectedSiteId,
121+
startServer,
122+
stopServer,
123+
loadingServer,
124+
setIsEditModalOpen,
125+
} = useSiteDetails();
126+
const { setSelectedTab } = useContentTabs();
127+
const { handleDeleteSite } = useDeleteSite();
112128
const isSelected = site === selectedSite;
113129
const { isSiteImporting, isSiteExporting } = useImportExport();
114130
const { isSiteIdPulling } = useSyncSites();
131+
const { data: editor } = useGetUserEditorQuery();
132+
const { data: terminal } = useGetUserTerminalQuery();
115133
const isImporting = isSiteImporting( site.id );
116134
const isExporting = isSiteExporting( site.id );
117135
const isPulling = isSiteIdPulling( site.id );
@@ -128,13 +146,111 @@ function SiteItem( { site }: { site: SiteDetails } ) {
128146
tooltipText = __( 'Loading' );
129147
}
130148

149+
const handleContextMenu = ( e: React.MouseEvent ) => {
150+
e.preventDefault();
151+
const ipcApi = getIpcApi();
152+
const isLoading = loadingServer[ site.id ] || false;
153+
const isAddingSite = site.isAddingSite || false;
154+
const finderLabel = isWindows() ? __( 'File Explorer' ) : __( 'Finder' );
155+
const editorLabel =
156+
editor && supportedEditorConfig[ editor ] ? supportedEditorConfig[ editor ].label : null;
157+
const terminalLabel = getTerminalName( terminal );
158+
159+
ipcApi.showSiteContextMenu( {
160+
siteId: site.id,
161+
isRunning: site.running,
162+
isLoading,
163+
isAddingSite,
164+
finderLabel,
165+
editorLabel,
166+
terminalLabel,
167+
} );
168+
};
169+
170+
useEffect( () => {
171+
const unsubscribe = window.ipcListener.subscribe(
172+
'site-context-menu-action',
173+
async ( _, data: { action: string; siteId: string } ) => {
174+
if ( data.siteId === site.id ) {
175+
const ipcApi = getIpcApi();
176+
switch ( data.action ) {
177+
case 'start':
178+
void startServer( site.id );
179+
break;
180+
case 'stop':
181+
void stopServer( site.id );
182+
break;
183+
case 'open-site':
184+
if ( ! site.running ) {
185+
await startServer( site.id );
186+
}
187+
ipcApi.openSiteURL( site.id, '', { autoLogin: false } );
188+
break;
189+
case 'open-admin':
190+
if ( ! site.running ) {
191+
await startServer( site.id );
192+
}
193+
ipcApi.openSiteURL( site.id, '/wp-admin/' );
194+
break;
195+
case 'open-finder':
196+
ipcApi.openLocalPath( site.path );
197+
break;
198+
case 'open-editor':
199+
if ( editor ) {
200+
void ipcApi.openAppAtPath( editor, site.path );
201+
}
202+
break;
203+
case 'open-terminal':
204+
void ( async () => {
205+
try {
206+
await ipcApi.openTerminalAtPath( site.path );
207+
} catch ( error ) {
208+
Sentry.captureException( error );
209+
alert( __( 'Could not open the terminal.' ) );
210+
}
211+
} )();
212+
break;
213+
case 'edit-site':
214+
if ( site.id !== selectedSite?.id ) {
215+
setSelectedSiteId( site.id );
216+
}
217+
setSelectedTab( 'settings' );
218+
setIsEditModalOpen( true );
219+
break;
220+
case 'delete':
221+
await handleDeleteSite( site.id, site.name );
222+
break;
223+
}
224+
}
225+
}
226+
);
227+
228+
return () => {
229+
unsubscribe?.();
230+
};
231+
}, [
232+
site.id,
233+
site.name,
234+
site.path,
235+
site.running,
236+
startServer,
237+
stopServer,
238+
editor,
239+
selectedSite?.id,
240+
setSelectedTab,
241+
setIsEditModalOpen,
242+
setSelectedSiteId,
243+
handleDeleteSite,
244+
] );
245+
131246
return (
132247
<li
133248
className={ cx(
134249
'flex flex-row min-w-[168px] h-8 hover:bg-[#ffffff0C] rounded transition-all ms-1',
135250
isMac() ? 'me-5' : 'me-4',
136251
isSelected && 'bg-[#ffffff19] hover:bg-[#ffffff19]'
137252
) }
253+
onContextMenu={ handleContextMenu }
138254
>
139255
<button
140256
className="p-2 text-xs rounded-tl rounded-bl whitespace-nowrap overflow-hidden text-ellipsis w-full text-left rtl:text-right focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-a8c-blue-50"

src/components/tests/content-tab-settings.test.tsx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,13 +269,28 @@ describe( 'ContentTabSettings', () => {
269269
updateSite,
270270
startServer,
271271
stopServer,
272+
isEditModalOpen: false,
273+
setIsEditModalOpen: jest.fn(),
272274
} );
273275

274276
const { rerender } = renderWithProvider(
275277
<ContentTabSettings selectedSite={ selectedSite } />
276278
);
277279
expect( screen.getByText( '8.3' ) ).toBeVisible();
278280
await user.click( screen.getByRole( 'button', { name: 'Edit site' } ) );
281+
( useSiteDetails as jest.Mock ).mockReturnValue( {
282+
selectedSite: { ...selectedSite, running: false } as SiteDetails,
283+
updateSite,
284+
startServer,
285+
stopServer,
286+
isEditModalOpen: true,
287+
setIsEditModalOpen: jest.fn(),
288+
} );
289+
rerenderWithProvider( rerender, <ContentTabSettings selectedSite={ selectedSite } /> );
290+
await waitFor( () => {
291+
expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument();
292+
} );
293+
279294
const dialog = screen.getByRole( 'dialog' );
280295
expect( dialog ).toBeVisible();
281296
await user.selectOptions( within( dialog ).getByLabelText( 'PHP version' ), '8.2' );
@@ -285,6 +300,16 @@ describe( 'ContentTabSettings', () => {
285300
} )
286301
);
287302

303+
( useSiteDetails as jest.Mock ).mockReturnValue( {
304+
selectedSite: { ...selectedSite, running: false } as SiteDetails,
305+
updateSite,
306+
startServer,
307+
stopServer,
308+
isEditModalOpen: false,
309+
setIsEditModalOpen: jest.fn(),
310+
} );
311+
rerenderWithProvider( rerender, <ContentTabSettings selectedSite={ selectedSite } /> );
312+
288313
await waitFor( () => {
289314
expect( updateSite ).toHaveBeenCalledWith(
290315
expect.objectContaining( { phpVersion: '8.2' } )
@@ -315,13 +340,27 @@ describe( 'ContentTabSettings', () => {
315340
updateSite,
316341
startServer,
317342
stopServer,
343+
isEditModalOpen: false,
344+
setIsEditModalOpen: jest.fn(),
318345
} );
319346

320347
const { rerender } = renderWithProvider(
321348
<ContentTabSettings selectedSite={ selectedSite } />
322349
);
323350
expect( screen.getByText( '8.3' ) ).toBeVisible();
324351
await user.click( screen.getByRole( 'button', { name: 'Edit site' } ) );
352+
( useSiteDetails as jest.Mock ).mockReturnValue( {
353+
selectedSite: { ...selectedSite, running: true } as SiteDetails,
354+
updateSite,
355+
startServer,
356+
stopServer,
357+
isEditModalOpen: true,
358+
setIsEditModalOpen: jest.fn(),
359+
} );
360+
rerenderWithProvider( rerender, <ContentTabSettings selectedSite={ selectedSite } /> );
361+
await waitFor( () => {
362+
expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument();
363+
} );
325364
const dialog = screen.getByRole( 'dialog' );
326365
expect( dialog ).toBeVisible();
327366
await user.selectOptions( within( dialog ).getByLabelText( 'PHP version' ), '8.2' );
@@ -331,6 +370,16 @@ describe( 'ContentTabSettings', () => {
331370
} )
332371
);
333372

373+
( useSiteDetails as jest.Mock ).mockReturnValue( {
374+
selectedSite: { ...selectedSite, running: true } as SiteDetails,
375+
updateSite,
376+
startServer,
377+
stopServer,
378+
isEditModalOpen: false,
379+
setIsEditModalOpen: jest.fn(),
380+
} );
381+
rerenderWithProvider( rerender, <ContentTabSettings selectedSite={ selectedSite } /> );
382+
334383
await waitFor( () => {
335384
expect( updateSite ).toHaveBeenCalledWith(
336385
expect.objectContaining( { phpVersion: '8.2' } )

src/components/tests/main-sidebar.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ jest.mock( 'src/lib/get-ipc-api', () => ( {
5050
generateProposedSitePath: jest.fn(),
5151
openURL: jest.fn(),
5252
getAllCustomDomains: jest.fn().mockResolvedValue( [] ),
53+
getUserEditor: jest.fn().mockResolvedValue( 'cursor' ),
54+
getUserTerminal: jest.fn().mockResolvedValue( 'terminal' ),
5355
setWindowControlVisibility: jest.fn(),
5456
} ),
5557
} ) );

src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ export const IPC_VOID_HANDLERS = < const >[
9393
'popupAppMenu',
9494
'setWindowButtonVisibility',
9595
'showErrorMessageBox',
96+
'showSiteContextMenu',
9697
'showItemInFolder',
9798
'showNotification',
9899
'authenticate',

0 commit comments

Comments
 (0)