Skip to content

Commit 6340abd

Browse files
authored
apps/ui: Add active site icons to the UI (#3266)
1 parent 4bd088b commit 6340abd

18 files changed

Lines changed: 336 additions & 11 deletions

File tree

apps/studio/src/ipc-handlers.ts

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -616,13 +616,26 @@ function readProcessManagerLogs( siteId: string ): { stdout?: string[]; stderr?:
616616
export async function getSiteDetails( _event: IpcMainInvokeEvent ): Promise< SiteDetails[] > {
617617
const sites = SiteServer.getAllDetails();
618618
const userData = await loadUserData();
619-
for ( const site of sites ) {
620-
const appdataSite = userData.siteMetadata[ site.id ];
621-
if ( appdataSite ) {
619+
await Promise.all(
620+
sites.map( async ( site ) => {
621+
const appdataSite = userData.siteMetadata[ site.id ];
622+
if ( ! appdataSite ) {
623+
return;
624+
}
622625
site.sortOrder = appdataSite.sortOrder;
623626
site.themeDetails = appdataSite.themeDetails;
624-
}
625-
}
627+
site.siteIconPath = appdataSite.siteIconPath;
628+
629+
// Read the icon file from disk and hand the renderer a data URL.
630+
// Keeping the base64 out of the persisted appdata avoids bloating
631+
// app.json with image bytes.
632+
if ( appdataSite.siteIconPath ) {
633+
site.siteIcon = await getImageData( appdataSite.siteIconPath );
634+
} else if ( appdataSite.siteIconPath === null ) {
635+
site.siteIcon = null;
636+
}
637+
} )
638+
);
626639

627640
return sites;
628641
}
@@ -694,6 +707,7 @@ export async function createSite(
694707
// If the site is running after creation, fetch theme details and update thumbnail
695708
if ( server.details.running ) {
696709
void loadThemeDetails( event, server.details.id );
710+
void loadSiteIcon( event, server.details.id );
697711
}
698712

699713
return server.details;
@@ -878,6 +892,7 @@ export async function startServer( event: IpcMainInvokeEvent, id: string ): Prom
878892

879893
if ( server.details.running ) {
880894
void loadThemeDetails( event, id );
895+
void loadSiteIcon( event, id );
881896
}
882897

883898
// Keep managed instruction files (STUDIO.md, CLAUDE.md) up-to-date
@@ -1264,6 +1279,29 @@ export async function loadThemeDetails(
12641279
return themeDetails;
12651280
}
12661281

1282+
// Mirror of loadThemeDetails for the Site Icon: fetch from the running
1283+
// site's mu-plugin command and persist the resolved path so the renderer
1284+
// can read it back from appdata via getSiteDetails.
1285+
export async function loadSiteIcon(
1286+
_event: IpcMainInvokeEvent,
1287+
id: string
1288+
): Promise< StartedSiteDetails[ 'siteIconPath' ] > {
1289+
const server = SiteServer.get( id );
1290+
if ( ! server ) {
1291+
throw new Error( 'Site not found.' );
1292+
}
1293+
1294+
const oldIconPath = server.details.siteIconPath;
1295+
const iconPath = await server.getSiteIcon();
1296+
const hasIconChanged = iconPath !== oldIconPath;
1297+
1298+
if ( hasIconChanged ) {
1299+
await server.persistSiteIcon();
1300+
}
1301+
1302+
return iconPath;
1303+
}
1304+
12671305
export async function getOnboardingData( _event: IpcMainInvokeEvent ): Promise< boolean > {
12681306
const userData = await loadUserData();
12691307
const { onboardingCompleted = false } = userData;

apps/studio/src/ipc-types.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ interface StoppedSiteDetails {
3131
supportsWidgets: boolean;
3232
supportsMenus: boolean;
3333
};
34+
// Absolute filesystem path of the configured WordPress Site Icon.
35+
// `null` means we've checked and the site has no icon configured;
36+
// `undefined` means we've never fetched.
37+
siteIconPath?: string | null;
38+
// Data URL produced from `siteIconPath` for the renderer to display.
39+
// Computed at the IPC boundary in `getSiteDetails`, never persisted.
40+
siteIcon?: string | null;
3441
isAddingSite?: boolean;
3542
autoStart?: boolean;
3643
latestCliPid?: number;

apps/studio/src/modules/cli/lib/cli-events-subscriber.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const STUDIO_ONLY_DETAIL_KEYS = [
2020
'tlsKey',
2121
'tlsCert',
2222
'themeDetails',
23+
'siteIconPath',
2324
'sortOrder',
2425
'isAddingSite',
2526
'latestCliPid',
@@ -104,6 +105,7 @@ const handleSiteEvent = sequential( async ( event: SiteEvent ): Promise< void >
104105
if ( wasNotRunning && running ) {
105106
void captureSiteThumbnail( siteId );
106107
await server.getThemeDetails();
108+
await server.getSiteIcon();
107109
}
108110
} );
109111

apps/studio/src/preload.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ const api: IpcApi = {
9797
showItemInFolder: ( path ) => ipcRendererSend( 'showItemInFolder', path ),
9898
loadThemeDetails: ( id, emitLoadingEvent = true ) =>
9999
ipcRendererInvoke( 'loadThemeDetails', id, emitLoadingEvent ),
100+
loadSiteIcon: ( id ) => ipcRendererInvoke( 'loadSiteIcon', id ),
100101
getThumbnailData: ( id ) => ipcRendererInvoke( 'getThumbnailData', id ),
101102
getInstalledAppsAndTerminals: () => ipcRendererInvoke( 'getInstalledAppsAndTerminals' ),
102103
importSite: ( siteId, importArchivePath, options ) =>

apps/studio/src/site-server.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,55 @@ export class SiteServer {
437437
}
438438
}
439439

440+
private static siteIconSchema = z.object( {
441+
relativePath: z.string(),
442+
} );
443+
444+
async getSiteIcon(): Promise< SiteDetails[ 'siteIconPath' ] > {
445+
if ( ! this.details.running ) {
446+
return this.details.siteIconPath;
447+
}
448+
449+
try {
450+
const { stdout, stderr, exitCode } = await this.executeWpCliCommand( [
451+
'studio',
452+
'get-site-icon',
453+
] );
454+
455+
if ( exitCode !== 0 ) {
456+
console.error( 'Failed to get site icon via WP-CLI', { exitCode, stdout, stderr } );
457+
return this.details.siteIconPath;
458+
}
459+
460+
const parsed = parseJsonFromPhpOutput( stdout );
461+
if ( parsed === null ) {
462+
this.details.siteIconPath = null;
463+
} else {
464+
const { relativePath } = SiteServer.siteIconSchema.parse( parsed );
465+
this.details.siteIconPath = nodePath.join( this.details.path, relativePath );
466+
}
467+
} catch ( error ) {
468+
console.error( 'Failed to get site icon:', error );
469+
}
470+
471+
return this.details.siteIconPath;
472+
}
473+
474+
async persistSiteIcon(): Promise< void > {
475+
try {
476+
await lockAppdata();
477+
const userData = await loadUserData();
478+
const siteId = this.details.id;
479+
userData.siteMetadata[ siteId ] = {
480+
...userData.siteMetadata[ siteId ],
481+
siteIconPath: this.details.siteIconPath,
482+
};
483+
await saveUserData( userData );
484+
} finally {
485+
await unlockAppdata();
486+
}
487+
}
488+
440489
async hasSQLitePlugin(): Promise< boolean > {
441490
const wpContentPath = nodePath.join( this.details.path, 'wp-content' );
442491

apps/studio/src/storage/storage-types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export interface WindowBounds {
1313

1414
export interface AppdataSiteData {
1515
themeDetails?: SiteDetails[ 'themeDetails' ];
16+
siteIconPath?: SiteDetails[ 'siteIconPath' ];
1617
sortOrder?: number;
1718
}
1819

apps/ui/src/components/session-view/index.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { Conversation } from '@/components/session-view/conversation';
2222
import { EmptyBackground } from '@/components/session-view/empty-background';
2323
import { QueuedPrompts } from '@/components/session-view/queued-prompts';
2424
import { SiteDropdown } from '@/components/site-dropdown';
25+
import { SiteIcon } from '@/components/site-icon';
2526
import { SitePreview } from '@/components/site-preview';
2627
import { type Annotation } from '@/components/site-preview/types';
2728
import { useConnector } from '@/data/core';
@@ -72,9 +73,16 @@ function SessionHeader( {
7273
<div className={ styles.header }>
7374
{ toggleSpacerClass ? <span className={ toggleSpacerClass } aria-hidden="true" /> : null }
7475
{ site ? (
75-
<SiteDropdown site={ site } activeEnvironment={ effectiveEnvironment } />
76+
<SiteDropdown
77+
site={ site }
78+
activeEnvironment={ effectiveEnvironment }
79+
showSiteIcon={ sidebarCollapsed }
80+
/>
7681
) : (
7782
<>
83+
{ sidebarCollapsed ? (
84+
<SiteIcon className={ styles.headerSiteIcon } seed={ siteName } />
85+
) : null }
7886
<span className={ styles.headerSite }>{ siteName }</span>
7987
<span className={ styles.headerDot } aria-hidden="true" />
8088
<span className={ styles.headerEnv }>

apps/ui/src/components/session-view/style.module.css

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
color: var(--wpds-color-fg-content-neutral-weak);
1919
}
2020

21-
2221
.header {
2322
display: flex;
2423
align-items: center;
@@ -52,6 +51,10 @@
5251
font-weight: 600;
5352
}
5453

54+
.headerSiteIcon {
55+
margin-inline-end: calc(-1 * var(--wpds-dimension-padding-xs));
56+
}
57+
5558
.headerDot {
5659
width: 6px;
5760
height: 6px;

apps/ui/src/components/site-dropdown/dropdown-trigger.module.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
gap: var(--wpds-dimension-padding-lg);
33
}
44

5+
.siteIcon {
6+
margin-inline-end: calc(-1 * var(--wpds-dimension-padding-sm));
7+
}
8+
59
.site {
610
font-weight: 600;
711
}

apps/ui/src/components/site-dropdown/dropdown-trigger.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { chevronDownSmall } from '@wordpress/icons';
33
import { Button, Icon } from '@wordpress/ui';
44
import { clsx } from 'clsx';
55
import { forwardRef } from 'react';
6+
import { SiteIcon } from '@/components/site-icon';
67
import styles from './dropdown-trigger.module.css';
78
import type { ComponentProps, ElementRef } from 'react';
89

@@ -14,11 +15,25 @@ type Props = Omit< ComponentProps< typeof Button >, 'children' > & {
1415
status: SiteStatus;
1516
statusLabel: string;
1617
environment: 'local' | 'live';
18+
showSiteIcon?: boolean;
19+
siteIconSeed?: string;
20+
siteIconImage?: string | null;
1721
};
1822

1923
export const DropdownTrigger = forwardRef< ElementRef< typeof Button >, Props >(
2024
function DropdownTrigger(
21-
{ siteName, siteUrl, status, statusLabel, environment, className, ...props },
25+
{
26+
siteName,
27+
siteUrl,
28+
status,
29+
statusLabel,
30+
environment,
31+
showSiteIcon = false,
32+
siteIconSeed,
33+
siteIconImage,
34+
className,
35+
...props
36+
},
2237
ref
2338
) {
2439
// In live mode the local server's running/stopped status is irrelevant
@@ -34,6 +49,13 @@ export const DropdownTrigger = forwardRef< ElementRef< typeof Button >, Props >(
3449
className={ clsx( styles.trigger, className ) }
3550
{ ...props }
3651
>
52+
{ showSiteIcon ? (
53+
<SiteIcon
54+
className={ styles.siteIcon }
55+
seed={ siteIconSeed ?? `${ siteName }:${ siteUrl }` }
56+
imageSrc={ siteIconImage }
57+
/>
58+
) : null }
3759
<span className={ styles.site }>{ siteName }</span>
3860
<span className={ styles.status }>
3961
<span className={ clsx( styles.dot, dotClass ) } role="img" aria-label={ statusLabel } />

0 commit comments

Comments
 (0)