From 1910e44374a6ec5c547d1860eed6764a416da968 Mon Sep 17 00:00:00 2001 From: Shaun Andrews Date: Mon, 23 Feb 2026 19:56:32 -0500 Subject: [PATCH 1/5] feat: add site icon fetching, caching, and IPC plumbing Fetch WordPress site icons via WP-CLI (site_icon_url option) with favicon.ico fallback. Cache as 64x64 PNGs alongside thumbnails. Wire up IPC events and handlers for loading, copying, and cleanup. Co-Authored-By: Claude Opus 4.6 --- apps/studio/src/ipc-handlers.ts | 31 ++++++++++++- apps/studio/src/ipc-utils.ts | 3 ++ apps/studio/src/preload.ts | 3 +- apps/studio/src/site-server.ts | 78 +++++++++++++++++++++++++++++++- apps/studio/src/storage/paths.ts | 4 ++ 5 files changed, 116 insertions(+), 3 deletions(-) diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index ea040162dc..8888bc5e5c 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -72,7 +72,7 @@ import { supportedEditorConfig, SupportedEditor } from 'src/modules/user-setting import { getUserTerminal } from 'src/modules/user-settings/lib/ipc-handlers'; import { winFindEditorPath } from 'src/modules/user-settings/lib/win-editor-path'; import { SiteServer, stopAllServers as triggerStopAllServers } from 'src/site-server'; -import { DEFAULT_SITE_PATH, getSiteThumbnailPath } from 'src/storage/paths'; +import { DEFAULT_SITE_PATH, getSiteIconPath, getSiteThumbnailPath } from 'src/storage/paths'; import { loadUserData, lockAppdata, @@ -608,6 +608,18 @@ export async function copySite( ); } + const sourceIconPath = getSiteIconPath( sourceSiteId ); + const newIconPath = getSiteIconPath( newSiteId ); + if ( fs.existsSync( sourceIconPath ) ) { + await fs.promises.copyFile( sourceIconPath, newIconPath ); + const iconData = await getImageData( newIconPath ); + sendIpcEventToRendererWithWindow( + BrowserWindow.fromWebContents( event.sender ), + 'site-icon-loaded', + { id: newSiteId, imageData: iconData } + ); + } + const port = await portFinder.getOpenPort(); const newSiteDetails: StoppedSiteDetails = { @@ -870,6 +882,18 @@ export async function loadThemeDetails( console.error( `Failed to update thumbnail for server ${ id }:`, error ); } + try { + sendIpcEventToRendererWithWindow( parentWindow, 'site-icon-loading', { id } ); + await server.updateCachedSiteIcon(); + const iconData = await getImageData( getSiteIconPath( id ) ); + sendIpcEventToRendererWithWindow( parentWindow, 'site-icon-loaded', { + id, + imageData: iconData, + } ); + } catch ( error ) { + sendIpcEventToRendererWithWindow( parentWindow, 'site-icon-load-error', { id } ); + } + return themeDetails; } @@ -920,6 +944,11 @@ export function getThumbnailData( _event: IpcMainInvokeEvent, id: string ) { return getImageData( path ); } +export function getSiteIconData( _event: IpcMainInvokeEvent, id: string ) { + const path = getSiteIconPath( id ); + return getImageData( path ); +} + function promiseExec( command: string, options: ExecOptions = {} ): Promise< void > { return new Promise( ( resolve, reject ) => { exec( command, options, ( error ) => { diff --git a/apps/studio/src/ipc-utils.ts b/apps/studio/src/ipc-utils.ts index 25cbc40425..d1260a3efe 100644 --- a/apps/studio/src/ipc-utils.ts +++ b/apps/studio/src/ipc-utils.ts @@ -50,6 +50,9 @@ export interface IpcEvents { 'thumbnail-loading': [ { id: string } ]; 'thumbnail-loaded': [ { id: string; imageData: string | null } ]; 'thumbnail-load-error': [ { id: string } ]; + 'site-icon-loading': [ { id: string } ]; + 'site-icon-loaded': [ { id: string; imageData: string | null } ]; + 'site-icon-load-error': [ { id: string } ]; 'user-settings': [ { tabName?: string } ]; 'window-fullscreen-change': [ boolean ]; 'user-preference-changed': [ void ]; diff --git a/apps/studio/src/preload.ts b/apps/studio/src/preload.ts index 9e30c143bb..5b29781c9c 100644 --- a/apps/studio/src/preload.ts +++ b/apps/studio/src/preload.ts @@ -1,7 +1,7 @@ // See the Electron documentation for details on how to use preload scripts: // https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts -import '@sentry/electron/preload'; +// import '@sentry/electron/preload'; // Crashes with Electron 39 — Sentry bug import { IpcRendererEvent, contextBridge, ipcRenderer, webUtils } from 'electron'; import { IpcEvents } from 'src/ipc-utils'; @@ -91,6 +91,7 @@ const api: IpcApi = { loadThemeDetails: ( id, emitThemeDetailsLoadingEvent = true ) => ipcRendererInvoke( 'loadThemeDetails', id, emitThemeDetailsLoadingEvent ), getThumbnailData: ( id ) => ipcRendererInvoke( 'getThumbnailData', id ), + getSiteIconData: ( id ) => ipcRendererInvoke( 'getSiteIconData', id ), getInstalledAppsAndTerminals: () => ipcRendererInvoke( 'getInstalledAppsAndTerminals' ), importSite: ( { id, backupFile } ) => ipcRendererInvoke( 'importSite', { id, backupFile } ), executeWPCLiInline: ( options ) => ipcRendererInvoke( 'executeWPCLiInline', options ), diff --git a/apps/studio/src/site-server.ts b/apps/studio/src/site-server.ts index 51953d085c..26e9f04bc5 100644 --- a/apps/studio/src/site-server.ts +++ b/apps/studio/src/site-server.ts @@ -1,4 +1,7 @@ +import { nativeImage } from 'electron'; import fs from 'fs'; +import http from 'node:http'; +import https from 'node:https'; import nodePath from 'path'; import * as Sentry from '@sentry/electron/main'; import { SQLITE_FILENAME } from '@studio/common/constants'; @@ -14,7 +17,7 @@ import { CliServerProcess } from 'src/modules/cli/lib/cli-server-process'; import { createSiteViaCli, type CreateSiteOptions } from 'src/modules/cli/lib/cli-site-creator'; import { executeCliCommand } from 'src/modules/cli/lib/execute-command'; import { createScreenshotWindow } from 'src/screenshot-window'; -import { getSiteThumbnailPath } from 'src/storage/paths'; +import { getSiteIconPath, getSiteThumbnailPath } from 'src/storage/paths'; import { loadUserData, lockAppdata, saveUserData, unlockAppdata } from 'src/storage/user-data'; import type { BlueprintV1Declaration } from '@wp-playground/blueprints'; @@ -77,6 +80,36 @@ function getAbsoluteUrl( details: SiteDetails ): string { return `http://localhost:${ details.port }`; } +function fetchImageBuffer( url: string ): Promise< Buffer > { + return new Promise( ( resolve, reject ) => { + const protocol = url.startsWith( 'https' ) ? https : http; + const request = protocol.get( url, { timeout: 5000, rejectUnauthorized: false }, ( res ) => { + if ( + res.statusCode && + res.statusCode >= 300 && + res.statusCode < 400 && + res.headers.location + ) { + fetchImageBuffer( res.headers.location ).then( resolve, reject ); + return; + } + if ( ! res.statusCode || res.statusCode >= 400 ) { + reject( new Error( `HTTP ${ res.statusCode } fetching ${ url }` ) ); + return; + } + const chunks: Buffer[] = []; + res.on( 'data', ( chunk: Buffer ) => chunks.push( chunk ) ); + res.on( 'end', () => resolve( Buffer.concat( chunks ) ) ); + res.on( 'error', reject ); + } ); + request.on( 'timeout', () => { + request.destroy(); + reject( new Error( `Timeout fetching ${ url }` ) ); + } ); + request.on( 'error', reject ); + } ); +} + // We use SiteDetails for storing it in appdata-v1.json, so this meta was introduced for extra data which is not stored locally type SiteServerMeta = { wpVersion?: string; @@ -191,6 +224,11 @@ export class SiteServer { await fs.promises.unlink( thumbnailPath ); } + const iconPath = getSiteIconPath( this.details.id ); + if ( fs.existsSync( iconPath ) ) { + await fs.promises.unlink( iconPath ); + } + await this.server.delete( deleteFiles ); deletedServers.push( this.details.id ); servers.delete( this.details.id ); @@ -300,6 +338,44 @@ export class SiteServer { .finally( () => window.destroy() ); } + async updateCachedSiteIcon() { + if ( ! this.details.running ) { + return; + } + + let iconUrl = ''; + + // Try WP-CLI first to get the site icon URL + try { + const { stdout, exitCode } = await this.executeWpCliCommand( + [ 'option', 'get', 'site_icon_url' ], + { skipPluginsAndThemes: true } + ); + if ( exitCode === 0 && stdout.trim() ) { + iconUrl = stdout.trim(); + } + } catch { + // WP-CLI failed, fall through to favicon fallback + } + + // Fall back to favicon.ico + if ( ! iconUrl ) { + iconUrl = `${ this.details.url }/favicon.ico`; + } + + const buffer = await fetchImageBuffer( iconUrl ); + const image = nativeImage.createFromBuffer( buffer ); + if ( image.isEmpty() ) { + throw new Error( 'Could not parse image from buffer' ); + } + + const resized = image.resize( { width: 64, height: 64 } ); + const outPath = getSiteIconPath( this.details.id ); + const outDir = nodePath.dirname( outPath ); + await fs.promises.mkdir( outDir, { recursive: true } ); + await fs.promises.writeFile( outPath, resized.toPNG() ); + } + async executeWpCliCommand( args: string | string[], { diff --git a/apps/studio/src/storage/paths.ts b/apps/studio/src/storage/paths.ts index 65d180fbf6..1ff0164e31 100644 --- a/apps/studio/src/storage/paths.ts +++ b/apps/studio/src/storage/paths.ts @@ -42,6 +42,10 @@ export function getSiteThumbnailPath( siteId: string ): string { return path.join( getAppDataPath(), getAppName(), 'thumbnails', `${ siteId }.png` ); } +export function getSiteIconPath( siteId: string ): string { + return path.join( getAppDataPath(), getAppName(), 'site-icons', `${ siteId }.png` ); +} + export function getResourcesPath(): string { if ( ! app ) { throw new Error( 'Electron app not available in child process' ); From 9b5755cc97167dbfc3696e7a59111f3b1aa0707c Mon Sep 17 00:00:00 2001 From: Shaun Andrews Date: Mon, 23 Feb 2026 19:56:38 -0500 Subject: [PATCH 2/5] feat: show site icons in sidebar with first-letter fallback Add SiteIcon component that renders the cached icon or a letter avatar. Extend ThemeDetailsContext with siteIcons state and update SiteItem to display the icon before each site name. Co-Authored-By: Claude Opus 4.6 --- apps/studio/src/components/site-icon.tsx | 41 +++++++++++++++++++ apps/studio/src/components/site-menu.tsx | 4 +- .../components/tests/main-sidebar.test.tsx | 13 +++++- apps/studio/src/hooks/use-theme-details.tsx | 21 +++++++++- 4 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 apps/studio/src/components/site-icon.tsx diff --git a/apps/studio/src/components/site-icon.tsx b/apps/studio/src/components/site-icon.tsx new file mode 100644 index 0000000000..6345d57a0b --- /dev/null +++ b/apps/studio/src/components/site-icon.tsx @@ -0,0 +1,41 @@ +import { useThemeDetails } from 'src/hooks/use-theme-details'; +import { cx } from 'src/lib/cx'; + +export function SiteIcon( { + siteId, + siteName, + size = 16, + className, +}: { + siteId: string; + siteName: string; + size?: number; + className?: string; +} ) { + const { siteIcons } = useThemeDetails(); + const iconData = siteIcons[ siteId ]; + + if ( iconData ) { + return ( + + ); + } + + return ( +
+ { siteName.charAt( 0 ).toUpperCase() } +
+ ); +} diff --git a/apps/studio/src/components/site-menu.tsx b/apps/studio/src/components/site-menu.tsx index 7c3c1b44bb..e4d62a980a 100644 --- a/apps/studio/src/components/site-menu.tsx +++ b/apps/studio/src/components/site-menu.tsx @@ -4,6 +4,7 @@ import { Spinner } from '@wordpress/components'; import { __, sprintf } from '@wordpress/i18n'; import { useEffect, useState } from 'react'; import { XDebugIcon } from 'src/components/icons/xdebug-icon'; +import { SiteIcon } from 'src/components/site-icon'; import { Tooltip } from 'src/components/tooltip'; import { useSyncSites } from 'src/hooks/sync-sites'; import { useContentTabs } from 'src/hooks/use-content-tabs'; @@ -220,9 +221,10 @@ function SiteItem( { onDrop={ ( e ) => onDrop( e, index ) } onDragEnd={ onDragEnd } > + { showSpinner ? ( @@ -388,11 +387,11 @@ export default function SiteMenu( { className }: SiteMenuProps ) { scrollbarGutter: 'stable', } } className={ cx( - 'w-full overflow-y-auto overflow-x-hidden flex flex-col gap-0.5 pb-4', + 'w-full overflow-y-auto overflow-x-hidden flex flex-col gap-1 px-2 pb-4', className ) } > -
    +
      { sites.map( ( site, index ) => ( Date: Mon, 23 Feb 2026 21:33:56 -0500 Subject: [PATCH 5/5] feat: add round-robin fallback icon colors for sidebar sites Sites without a custom icon now get a distinguishable muted color from an 8-color palette instead of a generic grey placeholder. Color is assigned at creation time and persisted to appdata. Existing sites get a deterministic color derived from their ID. Co-Authored-By: Claude Opus 4.6 --- apps/studio/src/components/site-icon.tsx | 41 +++++++++++++++++----- apps/studio/src/components/site-menu.tsx | 4 +-- apps/studio/src/hooks/use-site-details.tsx | 2 ++ apps/studio/src/ipc-handlers.ts | 7 +++- apps/studio/src/ipc-types.d.ts | 1 + apps/studio/src/site-server.ts | 25 ++++++++++--- apps/studio/src/storage/user-data.ts | 2 ++ apps/studio/src/tests/ipc-handlers.test.ts | 20 ++++++----- 8 files changed, 79 insertions(+), 23 deletions(-) diff --git a/apps/studio/src/components/site-icon.tsx b/apps/studio/src/components/site-icon.tsx index 6345d57a0b..67fd77f360 100644 --- a/apps/studio/src/components/site-icon.tsx +++ b/apps/studio/src/components/site-icon.tsx @@ -1,14 +1,38 @@ +import { WordPressLogoCircle } from 'src/components/wordpress-logo-circle'; import { useThemeDetails } from 'src/hooks/use-theme-details'; import { cx } from 'src/lib/cx'; +const SITE_ICON_COLORS = [ + '#5B8A72', // sage + '#7B6B8A', // lavender + '#8A7B5B', // khaki + '#5B7B8A', // steel + '#8A5B6B', // mauve + '#6B8A5B', // moss + '#5B6B8A', // slate + '#8A6B5B', // clay +]; + +/** + * Simple hash of a string to a number, used for deterministic + * color assignment for sites without an explicit iconColorIndex. + */ +function hashStringToIndex( str: string ): number { + let hash = 0; + for ( let i = 0; i < str.length; i++ ) { + hash = ( hash * 31 + str.charCodeAt( i ) ) | 0; + } + return Math.abs( hash ); +} + export function SiteIcon( { siteId, - siteName, + iconColorIndex, size = 16, className, }: { siteId: string; - siteName: string; + iconColorIndex?: number; size?: number; className?: string; } ) { @@ -27,15 +51,16 @@ export function SiteIcon( { ); } + const colorIndex = + typeof iconColorIndex === 'number' ? iconColorIndex : hashStringToIndex( siteId ); + const bgColor = SITE_ICON_COLORS[ colorIndex % SITE_ICON_COLORS.length ]; + return (
      - { siteName.charAt( 0 ).toUpperCase() } +
      ); } diff --git a/apps/studio/src/components/site-menu.tsx b/apps/studio/src/components/site-menu.tsx index eaea726f9d..a0d0746bd9 100644 --- a/apps/studio/src/components/site-menu.tsx +++ b/apps/studio/src/components/site-menu.tsx @@ -11,7 +11,7 @@ import { useContentTabs } from 'src/hooks/use-content-tabs'; import { useDeleteSite } from 'src/hooks/use-delete-site'; import { useImportExport } from 'src/hooks/use-import-export'; import { useSiteDetails } from 'src/hooks/use-site-details'; -import { isMac, isWindows } from 'src/lib/app-globals'; +import { isWindows } from 'src/lib/app-globals'; import { cx } from 'src/lib/cx'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { supportedEditorConfig } from 'src/modules/user-settings/lib/editor'; @@ -227,7 +227,7 @@ function SiteItem( { setSelectedSiteId( site.id ); } } > - + { site.name } { showSpinner ? ( diff --git a/apps/studio/src/hooks/use-site-details.tsx b/apps/studio/src/hooks/use-site-details.tsx index e9e805f5db..c2b1fbc090 100644 --- a/apps/studio/src/hooks/use-site-details.tsx +++ b/apps/studio/src/hooks/use-site-details.tsx @@ -328,6 +328,7 @@ export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) { running: false, isAddingSite: true, phpVersion: '', + iconColorIndex: prevData.length % 8, }, ] ) ); @@ -524,6 +525,7 @@ export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) { running: false, isAddingSite: true, phpVersion: sourceSite.phpVersion, + iconColorIndex: prevData.length % 8, }, ] ) ); diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index add3e13564..77d2a4c67a 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -264,6 +264,10 @@ export async function createSite( const siteId = providedSiteId || crypto.randomUUID(); + // Determine the icon color index for this site (round-robin from palette of 8 colors) + const existingSites = ( await loadUserData() ).sites; + const iconColorIndex = existingSites.length % 8; + const metric = getBlueprintMetric( blueprint?.slug ); bumpStat( StatsGroup.STUDIO_SITE_CREATE, metric ); @@ -280,7 +284,7 @@ export async function createSite( blueprint: blueprint?.blueprint, noStart, }, - { wpVersion, blueprint: blueprint?.blueprint } + { wpVersion, blueprint: blueprint?.blueprint, iconColorIndex } ); // If the site is running after creation, fetch theme details and update thumbnail @@ -636,6 +640,7 @@ export async function copySite( try { await lockAppdata(); const userData = await loadUserData(); + newSiteDetails.iconColorIndex = userData.sites.length % 8; userData.sites.push( newSiteDetails ); await saveUserData( userData ); } finally { diff --git a/apps/studio/src/ipc-types.d.ts b/apps/studio/src/ipc-types.d.ts index 00353d0da0..74a71a3e51 100644 --- a/apps/studio/src/ipc-types.d.ts +++ b/apps/studio/src/ipc-types.d.ts @@ -34,6 +34,7 @@ interface StoppedSiteDetails { enableDebugLog?: boolean; enableDebugDisplay?: boolean; sortOrder?: number; + iconColorIndex?: number; } interface StartedSiteDetails extends StoppedSiteDetails { diff --git a/apps/studio/src/site-server.ts b/apps/studio/src/site-server.ts index 29c6b19b16..e756530dcf 100644 --- a/apps/studio/src/site-server.ts +++ b/apps/studio/src/site-server.ts @@ -114,6 +114,7 @@ function fetchImageBuffer( url: string ): Promise< Buffer > { type SiteServerMeta = { wpVersion?: string; blueprint?: BlueprintV1Declaration; + iconColorIndex?: number; }; export class SiteServer { @@ -174,18 +175,34 @@ export class SiteServer { port: 0, phpVersion: options.phpVersion || '', running: false, + iconColorIndex: meta.iconColorIndex, }; const server = SiteServer.register( placeholderDetails, meta ); server.hasOngoingOperation = true; try { const result = await createSiteViaCli( { ...options, siteId } ); - const userData = await loadUserData(); - const siteData = userData.sites.find( ( s ) => s.id === result.id ); - if ( ! siteData ) { - throw new Error( `Site with ID ${ result.id } not found in appdata after CLI creation` ); + + // Persist iconColorIndex to appdata immediately so file-watcher + // reloads never see the site without it (prevents color flash). + let userData: Awaited< ReturnType< typeof loadUserData > >; + try { + await lockAppdata(); + userData = await loadUserData(); + const siteData = userData.sites.find( ( s ) => s.id === result.id ); + if ( ! siteData ) { + throw new Error( `Site with ID ${ result.id } not found in appdata after CLI creation` ); + } + if ( meta.iconColorIndex !== undefined ) { + siteData.iconColorIndex = meta.iconColorIndex; + await saveUserData( userData ); + } + } finally { + await unlockAppdata(); } + const siteData = userData.sites.find( ( s ) => s.id === result.id )!; + let siteDetails: SiteDetails; if ( result.running ) { const url = siteData.customDomain diff --git a/apps/studio/src/storage/user-data.ts b/apps/studio/src/storage/user-data.ts index d34cf32e76..d512acdd06 100644 --- a/apps/studio/src/storage/user-data.ts +++ b/apps/studio/src/storage/user-data.ts @@ -200,6 +200,7 @@ function toDiskFormat( { sites, ...rest }: UserData ): PersistedUserData { enableDebugLog, enableDebugDisplay, sortOrder, + iconColorIndex, } ) => { // 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. @@ -220,6 +221,7 @@ function toDiskFormat( { sites, ...rest }: UserData ): PersistedUserData { enableDebugLog, enableDebugDisplay, sortOrder, + iconColorIndex, themeDetails: { name: themeDetails?.name || '', path: themeDetails?.path || '', diff --git a/apps/studio/src/tests/ipc-handlers.test.ts b/apps/studio/src/tests/ipc-handlers.test.ts index 315e22cf82..db181e63a0 100644 --- a/apps/studio/src/tests/ipc-handlers.test.ts +++ b/apps/studio/src/tests/ipc-handlers.test.ts @@ -70,14 +70,17 @@ const mockSiteDetails: StoppedSiteDetails = { enableHttps: undefined, }; -vi.mocked( SiteServer.create ).mockResolvedValue( { - server: { - start: vi.fn(), - details: mockSiteDetails, - updateSiteDetails: vi.fn(), - updateCachedThumbnail: vi.fn().mockResolvedValue( undefined ), - } as unknown as SiteServer, - details: mockSiteDetails, +vi.mocked( SiteServer.create ).mockImplementation( async ( _options, meta ) => { + const details = { ...mockSiteDetails, iconColorIndex: meta?.iconColorIndex }; + return { + server: { + start: vi.fn(), + details, + updateSiteDetails: vi.fn(), + updateCachedThumbnail: vi.fn().mockResolvedValue( undefined ), + } as unknown as SiteServer, + details, + }; } ); vi.mocked( SiteServer.register, { partial: true } ).mockImplementation( ( details ) => ( { @@ -123,6 +126,7 @@ describe( 'createSite', () => { customDomain: undefined, enableHttps: undefined, isWpAutoUpdating: false, + iconColorIndex: 0, } ); expect( SiteServer.create ).toHaveBeenCalledWith(