Skip to content

feat: add site icons to sidebar with fallback colors#2651

Draft
shaunandrews wants to merge 5 commits intotrunkfrom
try/sidebar-site-icons
Draft

feat: add site icons to sidebar with fallback colors#2651
shaunandrews wants to merge 5 commits intotrunkfrom
try/sidebar-site-icons

Conversation

@shaunandrews
Copy link

@shaunandrews shaunandrews commented Feb 24, 2026

Summary

  • Fetches and caches custom site icons (favicons) from running WordPress sites using get_site_icon_url()
  • Sites without a custom icon get a distinguishable muted color from an 8-color palette with a centered WP logo
  • Color is assigned round-robin at creation time and persisted to appdata-v1.json
  • Existing sites without iconColorIndex get a deterministic color derived from hashing their site ID (no migration needed)
  • Widened sidebar from 210px to 230px
image

Test plan

  • Create a new site — verify it gets a colored fallback icon immediately (no color flash)
  • Create multiple sites — verify colors cycle through the palette
  • Delete a site and create another — color may repeat but stays stable
  • Copy a site — verify the copy gets its own distinct color
  • Set a custom site icon in WP admin — verify the real icon appears after refresh
  • Restart the app — verify colors persist across restarts
  • Verify existing sites (created before this change) show stable hash-derived colors

shaunandrews and others added 5 commits February 23, 2026 19:56
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…ning sites

The WP-CLI command `wp option get site_icon_url` doesn't work because
`site_icon_url` isn't a real WordPress option (the actual option is
`site_icon`, an attachment ID). Replace with `wp eval` calling
`get_site_icon_url(512)` which resolves the URL properly.

Also remove the broken `/favicon.ico` fallback (WordPress doesn't serve
a physical favicon.ico), add error logging for icon fetch failures,
refresh all running sites on window focus (not just selected), and
refresh on site selection change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Increase site icon from 16px to 28px, widen sidebar from 208px to
248px, and clean up sidebar spacing (remove left margin on site list,
move icon inside the button for better click targeting).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 ) => {

Check failure

Code scanning / CodeQL

Disabling certificate validation High

Disabling certificate validation is strongly discouraged.

Copilot Autofix

AI 6 days ago

In general, the problem is caused by explicitly setting rejectUnauthorized: false on an HTTPS request, which tells Node.js to skip verification of the server’s TLS certificate. The fix is to stop disabling this check: either remove the option entirely (so the default secure behavior applies) or explicitly set rejectUnauthorized: true. If there are legitimate issues with self‑signed or custom certificates, those should be handled via a proper CA configuration (e.g., ca option), not by turning off verification.

For this specific code, the best fix with minimal behavior change is:

  • Only pass TLS‑specific options when using HTTPS.
  • For HTTPS, either omit rejectUnauthorized or set it to true. Since the code doesn’t show any custom CA handling, omitting it and relying on the default (which is true) is simplest and standard.
  • For HTTP, keep the current behavior (no TLS options).

Concretely:

  • In apps/studio/src/site-server.ts, in fetchImageBuffer, replace the single protocol.get call that passes { timeout: 5000, rejectUnauthorized: false } with logic that:
    • Calls http.get(url, { timeout: 5000 }, ...) for HTTP URLs.
    • Calls https.get(url, { timeout: 5000 }, ...) for HTTPS URLs, without rejectUnauthorized: false.

No new imports or helper functions are strictly necessary.

Suggested changeset 1
apps/studio/src/site-server.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/apps/studio/src/site-server.ts b/apps/studio/src/site-server.ts
--- a/apps/studio/src/site-server.ts
+++ b/apps/studio/src/site-server.ts
@@ -82,26 +82,30 @@
 
 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;
+		const isHttps = url.startsWith( 'https' );
+		const request = ( isHttps ? https : http ).get(
+			url,
+			{ timeout: 5000 },
+			( 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 );
 			}
-			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 }` ) );
EOF
@@ -82,26 +82,30 @@

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;
const isHttps = url.startsWith( 'https' );
const request = ( isHttps ? https : http ).get(
url,
{ timeout: 5000 },
( 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 );
}
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 }` ) );
Copilot is powered by AI and may make mistakes. Always verify output.
@wpmobilebot
Copy link
Collaborator

📊 Performance Test Results

Comparing c62ecdf vs trunk

site-editor

Metric trunk c62ecdf Diff Change
load 1513.00 ms 1408.00 ms -105.00 ms 🟢 -6.9%

site-startup

Metric trunk c62ecdf Diff Change
siteCreation 7098.00 ms 7155.00 ms +57.00 ms 🔴 0.8%
siteStartup 3933.00 ms 3955.00 ms +22.00 ms ⚪ 0.0%

Results are median values from multiple test runs.

Legend: 🟢 Improvement (faster) | 🔴 Regression (slower) | ⚪ No change (<50ms diff)

@wojtekn wojtekn marked this pull request as draft February 24, 2026 07:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants