Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
39b0bc0
Add dragHandle icon to the menu item
mikaoelitiana Jan 21, 2026
1da00c7
Add drag and drop handler functions
mikaoelitiana Jan 21, 2026
c27153c
fix: spinner position
mikaoelitiana Jan 21, 2026
153bf1f
Add sortOrder field to StoppedSiteDetails type
mikaoelitiana Jan 28, 2026
e941b09
Update sortSites to prioritize sortOrder field over name
mikaoelitiana Jan 28, 2026
a44acd0
Add updateSitesSortOrder IPC handler for persisting site order
mikaoelitiana Jan 28, 2026
8f68d7c
Persist site sort order after drag and drop in site menu
mikaoelitiana Jan 28, 2026
2f2ff0d
Expose updateSitesSortOrder in preload script
mikaoelitiana Jan 28, 2026
f52d28c
Add tests for sortOrder sorting functionality
mikaoelitiana Jan 28, 2026
fd254fb
Move sort order persistence from useEffect to handleDrop
mikaoelitiana Jan 28, 2026
ccb2e5c
Add sortOrder to toDiskFormat to persist sort order to disk
mikaoelitiana Jan 28, 2026
ff01410
fix: remove duplicate tests
mikaoelitiana Jan 28, 2026
1e1d706
Add reorderSites to context for automatic site refresh after reorder
mikaoelitiana Jan 28, 2026
ab15f04
fix: remove local state
mikaoelitiana Jan 28, 2026
3589c66
Add drop zone at bottom of list for dragging to end
mikaoelitiana Jan 28, 2026
95e9adc
Increase drop zone height to match other items
mikaoelitiana Jan 28, 2026
d0b56ad
Fix drop zone height to h-8
mikaoelitiana Jan 28, 2026
81495e8
Add hover effect when dragging over site items
mikaoelitiana Jan 28, 2026
5890e9f
Extract common reorder logic into helper function
mikaoelitiana Jan 28, 2026
97d06c8
fix: simplify drag/drop logic
mikaoelitiana Jan 29, 2026
7ce988d
fix: reuse handleDragOver
mikaoelitiana Jan 29, 2026
74f9866
Fix duplicate site entry when dragging a site being added
mikaoelitiana Jan 29, 2026
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
19 changes: 17 additions & 2 deletions common/lib/sort-sites.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,18 @@
export function sortSites< T extends { name: string } >( sites: T[] ): T[] {
return sites.sort( ( a, b ) => a.name.localeCompare( b.name, undefined, { numeric: true } ) );
export function sortSites< T extends { name: string; sortOrder?: number } >( sites: T[] ): T[] {
return sites.sort( ( a, b ) => {
// If both have sortOrder, sort by sortOrder
if ( a.sortOrder !== undefined && b.sortOrder !== undefined ) {
return a.sortOrder - b.sortOrder;
}
// If only a has sortOrder, a comes first
if ( a.sortOrder !== undefined ) {
return -1;
}
// If only b has sortOrder, b comes first
if ( b.sortOrder !== undefined ) {
return 1;
}
// Neither has sortOrder, sort by name
return a.name.localeCompare( b.name, undefined, { numeric: true } );
} );
}
32 changes: 32 additions & 0 deletions common/lib/tests/sort-sites.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,36 @@ describe( 'sortSites', () => {
'Tristan',
] );
} );

it( 'should sort sites by sortOrder when available', () => {
const sites = [
{ name: 'Charlie', sortOrder: 3000 },
{ name: 'Alpha', sortOrder: 1000 },
{ name: 'Bravo', sortOrder: 2000 },
] as SiteDetails[];

const sortedSites = sortSites( sites );

expect( sortedSites.map( ( site ) => site.name ) ).toEqual( [ 'Alpha', 'Bravo', 'Charlie' ] );
} );

it( 'should prioritize sortOrder over name', () => {
const sites = [
{ name: 'Zulu', sortOrder: 3000 },
{ name: 'Charlie' },
{ name: 'Alpha', sortOrder: 1000 },
{ name: 'Bravo' },
{ name: 'Beta', sortOrder: 2000 },
] as SiteDetails[];

const sortedSites = sortSites( sites );

expect( sortedSites.map( ( site ) => site.name ) ).toEqual( [
'Alpha',
'Beta',
'Zulu',
'Bravo',
'Charlie',
] );
} );
} );
123 changes: 112 additions & 11 deletions src/components/site-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import * as Sentry from '@sentry/electron/renderer';
import { speak } from '@wordpress/a11y';
import { Spinner } from '@wordpress/components';
import { __, sprintf } from '@wordpress/i18n';
import { useEffect } from 'react';
import { Icon, dragHandle } from '@wordpress/icons';
import { useEffect, useState } from 'react';
import { XDebugIcon } from 'src/components/icons/xdebug-icon';
import { Tooltip } from 'src/components/tooltip';
import { useSyncSites } from 'src/hooks/sync-sites';
Expand Down Expand Up @@ -46,6 +47,7 @@ function ButtonToRun( {
const classCircle = `rounded-full`;
const triangle = (
<svg
aria-hidden="true"
width="8"
height="10"
viewBox="0 0 8 10"
Expand All @@ -63,7 +65,14 @@ function ButtonToRun( {
);

const rectangle = (
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg
aria-hidden="true"
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0.25 2C0.25 1.0335 1.0335 0.25 2 0.25H8C8.9665 0.25 9.75 1.0335 9.75 2V8C9.75 8.9665 8.9665 9.75 8 9.75H2C1.0335 9.75 0.25 8.9665 0.25 8V2Z"
fill="#FF8085"
Expand All @@ -82,6 +91,7 @@ function ButtonToRun( {
return (
<Tooltip text={ tooltipText }>
<button
type="button"
aria-disabled={ loadingServer[ id ] }
onClick={ () => {
if ( loadingServer[ id ] ) {
Expand Down Expand Up @@ -132,7 +142,23 @@ function ButtonToRun( {
</Tooltip>
);
}
function SiteItem( { site }: { site: SiteDetails } ) {
function SiteItem( {
site,
index,
onDragStart,
onDragOver,
onDrop,
onDragEnd,
isDragOver,
}: {
site: SiteDetails;
index: number;
onDragStart: ( e: React.DragEvent, index: number ) => void;
onDragOver: ( e: React.DragEvent, index: number ) => void;
onDrop: ( e: React.DragEvent, index: number ) => void;
onDragEnd: () => void;
isDragOver: boolean;
} ) {
const { selectedSite, setSelectedSiteId, loadingServer, isSiteDeleting } = useSiteDetails();
const isSelected = site === selectedSite;
const { isSiteImporting, isSiteExporting } = useImportExport();
Expand All @@ -148,7 +174,7 @@ function SiteItem( { site }: { site: SiteDetails } ) {
const showSpinner =
site.isAddingSite || isImporting || isPulling || isPushing || isExporting || isDeleting;

let tooltipText;
let tooltipText: string;
if ( site.isAddingSite ) {
tooltipText = __( 'Adding' );
} else if ( isImporting ) {
Expand Down Expand Up @@ -184,13 +210,21 @@ function SiteItem( { site }: { site: SiteDetails } ) {
return (
<li
className={ cx(
'flex flex-row min-w-[168px] h-8 hover:bg-[#ffffff0C] rounded transition-all ms-1',
'flex flex-row min-w-[168px] h-8 hover:bg-[#ffffff0C] rounded transition-all ms-1 items-center',
isMac() ? 'me-5' : 'me-4',
isSelected && 'bg-[#ffffff19] hover:bg-[#ffffff19]'
isSelected && 'bg-[#ffffff19] hover:bg-[#ffffff19]',
isDragOver && 'bg-[#ffffff26]'
) }
onContextMenu={ handleContextMenu }
draggable
onDragStart={ ( e ) => onDragStart( e, index ) }
onDragOver={ ( e ) => onDragOver( e, index ) }
onDrop={ ( e ) => onDrop( e, index ) }
onDragEnd={ onDragEnd }
>
<Icon icon={ dragHandle } className="fill-white" />
<button
type="button"
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"
onClick={ () => {
setSelectedSiteId( site.id );
Expand All @@ -201,7 +235,7 @@ function SiteItem( { site }: { site: SiteDetails } ) {
{ showSpinner ? (
<Tooltip text={ tooltipText }>
<div className="grid place-items-center">
<Spinner className="!w-2.5 !h-2.5 !top-[6px] !mr-2 [&>circle]:stroke-a8c-gray-70" />
<Spinner className="!w-2.5 !h-2.5 !mt-0 !mr-2 [&>circle]:stroke-a8c-gray-70" />
</div>
</Tooltip>
) : (
Expand All @@ -212,11 +246,63 @@ function SiteItem( { site }: { site: SiteDetails } ) {
}

export default function SiteMenu( { className }: SiteMenuProps ) {
const { sites, selectedSite, setSelectedSiteId, startServer, stopServer, setIsEditModalOpen } =
useSiteDetails();
const {
sites,
selectedSite,
setSelectedSiteId,
startServer,
stopServer,
setIsEditModalOpen,
reorderSites,
} = useSiteDetails();
const { setSelectedTab } = useContentTabs();
const { handleDeleteSite } = useDeleteSite();
const { data: editor } = useGetUserEditorQuery();
const [ draggedIndex, setDraggedIndex ] = useState< number | null >( null );
const [ dragOverIndex, setDragOverIndex ] = useState< number | null >( null );

const handleDragStart = ( e: React.DragEvent, index: number ) => {
setDraggedIndex( index );
e.dataTransfer.effectAllowed = 'move';
};

const handleDragOver = ( e: React.DragEvent, index: number ) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if ( draggedIndex !== null && draggedIndex !== index ) {
setDragOverIndex( index );
}
};

const reorderSitesToNewPositions = ( newSites: SiteDetails[] ) => {
const updates = newSites.map( ( site, index ) => ( {
siteId: site.id,
sortOrder: ( index + 1 ) * 1000,
} ) );

reorderSites( updates ).catch( ( error ) => {
console.error( 'Failed to save site order:', error );
} );
};

const handleDrop = ( e: React.DragEvent, targetIndex: number ) => {
e.preventDefault();
setDragOverIndex( null );
if ( draggedIndex === null || draggedIndex === targetIndex ) {
return;
}

const newSites = [ ...sites ];
const [ movedSite ] = newSites.splice( draggedIndex, 1 );
newSites.splice( targetIndex, 0, movedSite );

reorderSitesToNewPositions( newSites );
};

const handleDragEnd = () => {
setDraggedIndex( null );
setDragOverIndex( null );
};

useEffect( () => {
const unsubscribe = window.ipcListener.subscribe(
Expand Down Expand Up @@ -306,9 +392,24 @@ export default function SiteMenu( { className }: SiteMenuProps ) {
) }
>
<ul className="pt-px">
{ sites.map( ( site ) => (
<SiteItem key={ site.id } site={ site } />
{ sites.map( ( site, index ) => (
<SiteItem
key={ site.id }
site={ site }
index={ index }
onDragStart={ handleDragStart }
onDragOver={ handleDragOver }
onDrop={ handleDrop }
onDragEnd={ handleDragEnd }
isDragOver={ dragOverIndex === index }
/>
) ) }
{ /* Drop zone for dragging to bottom of list */ }
<li
className="h-8"
onDragOver={ ( e ) => handleDragOver( e, sites.length ) }
onDrop={ ( e ) => handleDrop( e, sites.length ) }
/>
</ul>
</nav>
);
Expand Down
12 changes: 11 additions & 1 deletion src/hooks/use-site-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type { Blueprint } from 'src/stores/wpcom-api';
interface SiteDetailsContext {
selectedSite: SiteDetails | null;
updateSite: ( site: SiteDetails, wpVersion?: string ) => Promise< void >;
reorderSites: ( updates: { siteId: string; sortOrder: number }[] ) => Promise< void >;
sites: SiteDetails[];
setSelectedSiteId: ( selectedSiteId: string ) => void;
createSite: (
Expand Down Expand Up @@ -54,6 +55,7 @@ interface SiteDetailsContext {
const defaultContext: SiteDetailsContext = {
selectedSite: null,
updateSite: async () => undefined,
reorderSites: async () => undefined,
sites: [],
siteCreationMessages: {},
setSelectedSiteId: () => undefined,
Expand Down Expand Up @@ -355,7 +357,7 @@ export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) {
} );
setSites( ( prevData ) =>
sortSites( [
...prevData.filter( ( site ) => site.id !== tempSiteId ),
...prevData.filter( ( site ) => site.id !== tempSiteId && site.id !== newSite.id ),
{ ...newSite, isAddingSite: true },
] )
);
Expand Down Expand Up @@ -392,6 +394,12 @@ export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) {
setSites( updatedSites );
}, [] );

const reorderSites = useCallback( async ( updates: { siteId: string; sortOrder: number }[] ) => {
await getIpcApi().updateSitesSortOrder( updates );
const updatedSites = await getIpcApi().getSiteDetails();
setSites( updatedSites );
}, [] );

const startServer = useCallback(
async ( id: string ) => {
toggleLoadingServerForSite( id );
Expand Down Expand Up @@ -518,6 +526,7 @@ export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) {
setSelectedSiteId,
createSite,
updateSite,
reorderSites,
startServer,
stopServer,
stopAllRunningSites,
Expand All @@ -538,6 +547,7 @@ export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) {
setSelectedSiteId,
createSite,
updateSite,
reorderSites,
startServer,
stopServer,
stopAllRunningSites,
Expand Down
22 changes: 22 additions & 0 deletions src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1408,3 +1408,25 @@ export async function setWindowControlVisibility( event: IpcMainInvokeEvent, vis
parentWindow.setWindowButtonVisibility( visible );
}
}

export async function updateSitesSortOrder(
event: IpcMainInvokeEvent,
updates: { siteId: string; sortOrder: number }[]
): Promise< void > {
try {
await lockAppdata();
const userData = await loadUserData();

const updatedSites = userData.sites.map( ( site ) => {
const update = updates.find( ( u ) => u.siteId === site.id );
if ( update ) {
return { ...site, sortOrder: update.sortOrder };
}
return site;
} );

await saveUserData( { ...userData, sites: updatedSites } );
} finally {
await unlockAppdata();
}
}
1 change: 1 addition & 0 deletions src/ipc-types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ interface StoppedSiteDetails {
autoStart?: boolean;
latestCliPid?: number;
enableXdebug?: boolean;
sortOrder?: number;
}

interface StartedSiteDetails extends StoppedSiteDetails {
Expand Down
1 change: 1 addition & 0 deletions src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ const api: IpcApi = {
showSiteContextMenu: ( context ) => ipcRendererSend( 'showSiteContextMenu', context ),
setWindowControlVisibility: ( visible ) =>
ipcRendererInvoke( 'setWindowControlVisibility', visible ),
updateSitesSortOrder: ( updates ) => ipcRendererInvoke( 'updateSitesSortOrder', updates ),
isStudioCliInstalled: () => ipcRendererInvoke( 'isStudioCliInstalled' ),
installStudioCli: () => ipcRendererInvoke( 'installStudioCli' ),
uninstallStudioCli: () => ipcRendererInvoke( 'uninstallStudioCli' ),
Expand Down
2 changes: 2 additions & 0 deletions src/storage/user-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ function toDiskFormat( { sites, ...rest }: UserData ): PersistedUserData {
autoStart,
latestCliPid,
enableXdebug,
sortOrder,
} ) => {
// No object spreading allowed. TypeScript's structural typing is too permissive and
// will permit us to persist properties that aren't in the type definition.
Expand All @@ -184,6 +185,7 @@ function toDiskFormat( { sites, ...rest }: UserData ): PersistedUserData {
autoStart,
latestCliPid,
enableXdebug,
sortOrder,
themeDetails: {
name: themeDetails?.name || '',
path: themeDetails?.path || '',
Expand Down