Skip to content

feat(desktop): add project icon support with custom protocol#1377

Closed
saddlepaddle wants to merge 1 commit into
mainfrom
handle-priyank-pr
Closed

feat(desktop): add project icon support with custom protocol#1377
saddlepaddle wants to merge 1 commit into
mainfrom
handle-priyank-pr

Conversation

@saddlepaddle
Copy link
Copy Markdown
Collaborator

@saddlepaddle saddlepaddle commented Feb 10, 2026

Summary

  • Store project icons on disk at ~/.superset/project-icons/{projectId}.{ext} and serve them via a custom superset-icon:// Electron protocol — making <img src={project.iconUrl}> work transparently
  • Add automatic favicon discovery from project directories (searches for favicon.ico, logo.png, etc.)
  • Add manual icon upload/remove in Project Settings UI
  • Thread iconUrl through the sidebar component chain (WorkspaceSidebarProjectSectionProjectHeaderProjectThumbnail)

Changes

Backend (main process):

  • project-icons.ts — new utility for icon disk storage (save from file/data URL/buffer, delete, lookup)
  • favicon-discovery.ts — auto-discovers favicons in project repos using fast-glob with sensible ignore patterns
  • main/index.ts — registers superset-icon:// protocol on both default and persist:superset sessions
  • projects.ts — adds triggerFaviconDiscovery and setProjectIcon tRPC mutations

Schema:

  • Adds single icon_url text column to projects table (migration 0020)

Frontend:

  • ProjectThumbnail — renders icon with priority: iconUrl → GitHub avatar → first letter fallback
  • ProjectSettings — adds "Project Icon" section with upload/replace/remove controls
  • Props threaded through WorkspaceSidebarProjectSectionProjectHeader

Test plan

  • Open a project with a favicon.ico in its root → triggers auto-discovery, icon appears in sidebar
  • Upload a custom icon in Project Settings → replaces sidebar icon
  • Remove the icon → falls back to GitHub avatar or letter
  • Restart app → icons persist (served from disk via protocol)
  • bun run typecheck passes (verified)
  • bun run test passes (verified — 1205 pass, 0 fail)

Summary by CodeRabbit

  • New Features
    • Project icons: Upload custom icons for projects or enable automatic discovery of project icons from repositories
    • Icon display: Project icons now appear in the sidebar and workspace lists for improved visual identification
    • Icon management: Upload, replace, or remove project icons at any time from project settings

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 10, 2026

📝 Walkthrough

Walkthrough

The changes implement a complete project icon management system that enables users to upload, discover, and display custom icons for projects. This includes database schema extension, backend TRPC procedures for icon operations, file storage utilities using a custom protocol handler, favicon auto-discovery functionality, and UI components for uploading and displaying project icons.

Changes

Cohort / File(s) Summary
Database Schema
packages/local-db/drizzle/0020_add_icon_url_to_projects.sql, packages/local-db/drizzle/meta/..., packages/local-db/src/schema/schema.ts
Added icon_url text column to projects table and updated DrizzleKit metadata snapshots and journal.
Icon File Management
apps/desktop/src/main/lib/project-icons.ts
New utility module for managing project icon assets: save from file/data URL/buffer, delete icons, retrieve paths, and generate protocol URLs. Enforces 512 KB size limit and manages files on disk with the superset-icon:// protocol scheme.
Favicon Discovery
apps/desktop/src/lib/trpc/routers/projects/utils/favicon-discovery.ts
New utility that discovers project favicons from common file patterns within a repository, validates size (256 KB max), and saves via icon utilities. Returns protocol URL on success or null if no icon found.
Projects Router
apps/desktop/src/lib/trpc/routers/projects/projects.ts
Added two new TRPC procedures: triggerFaviconDiscovery (discovers and caches project favicon) and setProjectIcon (saves or deletes project icon with null support).
Workspace Query Integration
apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts
Extended getAllGrouped response to include optional `iconUrl: string
Electron Main Protocol
apps/desktop/src/main/index.ts
Registered superset-icon:// protocol handler that translates icon requests into disk file paths and serves via network fetch. Initializes icons directory at startup and hooks protocol handling into both main app and deep-link flows.
Sidebar Components
apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx, ...ProjectSection/ProjectSection.tsx, ...ProjectSection/ProjectHeader.tsx, ...ProjectThumbnail/ProjectThumbnail.tsx
Propagated iconUrl prop through component hierarchy from WorkspaceSidebar down to ProjectThumbnail, which now renders project icon with fallback to GitHub avatar on load failure.
Project Settings UI
apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx
Added icon upload/remove UI in project settings with file input, image preview, and mutation hook for setProjectIcon. Integrates cache invalidation on icon changes.

Sequence Diagram(s)

sequenceDiagram
    participant User as User
    participant UI as ProjectSettings
    participant TRPC as setProjectIcon<br/>(TRPC)
    participant Icons as project-icons<br/>utilities
    participant FS as File System
    participant DB as Database

    User->>UI: Select & upload image file
    UI->>UI: Read as data URL
    UI->>TRPC: mutate({ id, icon: dataUrl })
    TRPC->>Icons: saveProjectIconFromDataUrl()
    Icons->>Icons: Decode base64, validate size
    Icons->>FS: Write icon file
    FS-->>Icons: File saved
    Icons->>DB: Update project.iconUrl
    DB-->>Icons: Updated
    Icons-->>TRPC: Return protocol URL
    TRPC-->>UI: Success, invalidate cache
    UI->>User: Show updated icon
Loading
sequenceDiagram
    participant User as User
    participant Sidebar as ProjectThumbnail
    participant Protocol as superset-icon<br/>handler
    participant FS as File System
    participant Display as Browser render

    User->>Sidebar: View project list
    Sidebar->>Sidebar: Receive iconUrl prop
    Sidebar->>Protocol: Fetch superset-icon://projects/{id}
    Protocol->>FS: getProjectIconPath(projectId)
    FS-->>Protocol: File path
    Protocol->>FS: Read icon file
    FS-->>Protocol: Icon bytes
    Protocol-->>Sidebar: Icon data
    Sidebar->>Display: Render <img>
    Display-->>User: Show project icon
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 Icons spring from the digital ground,
Each project now wears a crown,
Favicons discovered, uploaded with care,
Custom pictures floating through air!
Protocol paths lead to icons so fair, 🎨✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 64.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main feature: adding project icon support with a custom protocol for serving icons via Electron.
Description check ✅ Passed The description provides a comprehensive overview of changes across backend, schema, and frontend, with clear sections, a detailed test plan, and alignment to the template structure.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch handle-priyank-pr

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@apps/desktop/src/lib/trpc/routers/projects/utils/favicon-discovery.ts`:
- Around line 48-58: The current selection of iconPath relies on fast-glob
results but fg does not guarantee pattern-priority ordering, so change the logic
after the fg(...) call to pick the highest-priority match based on
FAVICON_PATTERNS: build a lookup of matches, then iterate FAVICON_PATTERNS in
order and for each pattern check which matched paths correspond to it (e.g.,
using minimatch or by re-testing the pattern against each match) and choose the
first hit as iconPath; update the code referencing matches[0] to use this
prioritized selection while preserving ignore/cwd/absolute settings from the
original fg call.

In `@apps/desktop/src/main/lib/project-icons.ts`:
- Around line 91-95: The data URL regex in project-icons.ts (the
dataUrl.match(...) call) incorrectly uses \w+ so it rejects MIME types like
image/svg+xml; update that regex to accept characters used in MIME subtype names
(e.g., plus, dot, hyphen and alphanumerics) or use a more permissive token for
the subtype (for example match [a-zA-Z0-9.+-]+ or [^;]+) so image/svg+xml is
accepted and SVG uploads no longer throw "Invalid data URL format".

In
`@apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectThumbnail/ProjectThumbnail.tsx`:
- Around line 46-47: The iconError state isn't reset when iconUrl changes so a
previously failed icon blocks rendering new icons; add a useEffect that watches
the iconUrl prop and calls setIconError(false) whenever iconUrl changes (also
ensure useEffect is imported from react) so the <img> in ProjectThumbnail can
attempt to load the new icon; reference the iconError state setter setIconError
and the iconUrl prop in your effect.
🧹 Nitpick comments (5)
apps/desktop/src/lib/trpc/routers/projects/utils/favicon-discovery.ts (1)

73-78: Redundant Buffer.from()readFile already returns a Buffer.

readFile(iconPath) returns a Buffer in Node.js. Wrapping it in Buffer.from(buffer) creates an unnecessary copy.

Proposed fix
 	if (ext === "ico") {
 		const buffer = await readFile(iconPath);
 		return await saveProjectIconFromBuffer({
 			projectId,
-			buffer: Buffer.from(buffer),
+			buffer,
 			ext: "ico",
 		});
 	}
apps/desktop/src/lib/trpc/routers/projects/projects.ts (1)

1100-1145: Invalid data URL input surfaces as INTERNAL_SERVER_ERROR instead of BAD_REQUEST.

If input.icon is a non-null string that isn't a valid data URL, saveProjectIconFromDataUrl throws "Invalid data URL format". Without a try-catch, this propagates as an opaque INTERNAL_SERVER_ERROR to the client. Wrapping the save call would give a more descriptive error:

Proposed fix
 			// Save icon from data URL
-			const iconUrl = await saveProjectIconFromDataUrl({
-				projectId: input.id,
-				dataUrl: input.icon,
-			});
+			let iconUrl: string;
+			try {
+				iconUrl = await saveProjectIconFromDataUrl({
+					projectId: input.id,
+					dataUrl: input.icon,
+				});
+			} catch (error) {
+				throw new TRPCError({
+					code: "BAD_REQUEST",
+					message: error instanceof Error ? error.message : "Failed to save icon",
+				});
+			}
apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx (1)

104-120: No client-side file size validation before uploading.

The file is read entirely into memory as a data URL before the mutation sends it to the main process, which then rejects files over 512KB. Adding a quick size check in handleFileChange would avoid unnecessary work and give the user immediate feedback.

Proposed fix
 const handleFileChange = useCallback(
   (e: React.ChangeEvent<HTMLInputElement>) => {
     const file = e.target.files?.[0];
     if (!file) return;

+    const MAX_ICON_SIZE_KB = 512;
+    if (file.size > MAX_ICON_SIZE_KB * 1024) {
+      console.warn(`[project-settings] Icon too large: ${Math.round(file.size / 1024)}KB`);
+      e.target.value = "";
+      return;
+    }
+
     const reader = new FileReader();
     reader.onload = () => {
       const dataUrl = reader.result as string;
       setProjectIcon.mutate({ id: projectId, icon: dataUrl });
     };
     reader.readAsDataURL(file);

     // Reset input so the same file can be re-selected
     e.target.value = "";
   },
   [projectId, setProjectIcon],
 );

Consider also showing a toast to the user when the file is too large.

apps/desktop/src/main/lib/project-icons.ts (2)

59-74: saveProjectIconFromFile skips the file size check that other save methods enforce.

Both saveProjectIconFromDataUrl and saveProjectIconFromBuffer validate against MAX_ICON_SIZE, but saveProjectIconFromFile copies the file without any size check. While the current caller (favicon-discovery.ts) has its own upstream size check (256KB), this public function could be called from new code without that safeguard.

Proposed fix
 export async function saveProjectIconFromFile({
 	projectId,
 	sourcePath,
 }: {
 	projectId: string;
 	sourcePath: string;
 }): Promise<string> {
 	ensureProjectIconsDir();
 	removeExistingIcon(projectId);

+	const { size } = await import("node:fs/promises").then(m => m.stat(sourcePath));
+	if (size > MAX_ICON_SIZE) {
+		throw new Error(
+			`Icon file too large (${Math.round(size / 1024)}KB). Maximum is ${MAX_ICON_SIZE / 1024}KB.`,
+		);
+	}
+
 	const ext = extname(sourcePath) || ".png";
 	const destPath = join(PROJECT_ICONS_DIR, `${projectId}${ext}`);
 	await copyFile(sourcePath, destPath);

 	return getProjectIconProtocolUrl(projectId);
 }

Or more simply, since stat is already imported at the top of the file (from node:fs/promises):

+import { copyFile, stat, writeFile } from "node:fs/promises";
...
 export async function saveProjectIconFromFile({
 	projectId,
 	sourcePath,
 }: {
 	projectId: string;
 	sourcePath: string;
 }): Promise<string> {
 	ensureProjectIconsDir();
+
+	const fileStat = await stat(sourcePath);
+	if (fileStat.size > MAX_ICON_SIZE) {
+		throw new Error(
+			`Icon file too large (${Math.round(fileStat.size / 1024)}KB). Maximum is ${MAX_ICON_SIZE / 1024}KB.`,
+		);
+	}
+
 	removeExistingIcon(projectId);

 	const ext = extname(sourcePath) || ".png";

25-35: Edge case: files without extensions cause incorrect matching.

On line 30, f.substring(0, f.lastIndexOf(".")) — if a file somehow has no extension, lastIndexOf(".") returns -1, and substring(0, -1) returns all characters except the last. For example, a file named abc123 would produce abc12 as the name. This is unlikely since all save functions add extensions, but a defensive check would be safer.

Proposed fix
 const match = files.find((f) => {
-	const name = f.substring(0, f.lastIndexOf("."));
+	const dotIdx = f.lastIndexOf(".");
+	const name = dotIdx !== -1 ? f.substring(0, dotIdx) : f;
 	return name === projectId;
 });

Comment on lines +48 to +58
const matches = await fg(FAVICON_PATTERNS, {
cwd: repoPath,
absolute: true,
ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/build/**"],
onlyFiles: true,
});

if (matches.length === 0) return null;

// Use the first match (ordered by FAVICON_PATTERNS priority)
const iconPath = matches[0];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

fast-glob pattern order results documentation

💡 Result:

In fast-glob (Node.js):

  • Result ordering is not guaranteed: matched paths are returned in arbitrary order, so you must sort the returned array yourself if you need deterministic ordering. [1][2]
  • Pattern list order is not respected: when you pass multiple patterns, all negative patterns (!…) are applied first, and only then the positive patterns—regardless of the order you wrote them in. If you need “pattern A results first, then pattern B”, you must split into multiple calls and combine/sort manually. [3]

Sources:
[1] fast-glob README (GitHub) — “results are returned in arbitrary order”
[2] fast-glob package page (npm) — same statement
[3] fast-glob README (GitHub) — “does not respect the order of patterns… negative… then positive… use sorting or split calls”


fast-glob does not preserve input pattern order in results.

The comment on line 57 states results are "ordered by FAVICON_PATTERNS priority," but fast-glob returns matches in arbitrary filesystem traversal order. This means icons may be selected in unintended priority (e.g., public/logo.svg before favicon.ico), contradicting the intention of the FAVICON_PATTERNS array.

Sort matches by pattern priority after globbing:

Proposed fix
 	const matches = await fg(FAVICON_PATTERNS, {
 		cwd: repoPath,
 		absolute: true,
 		ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/build/**"],
 		onlyFiles: true,
 	});

 	if (matches.length === 0) return null;

-	// Use the first match (ordered by FAVICON_PATTERNS priority)
-	const iconPath = matches[0];
+	// Sort matches by FAVICON_PATTERNS priority (fast-glob doesn't preserve pattern order)
+	const relativize = (abs: string) => abs.replace(`${repoPath}/`, "").replace(`${repoPath}\\`, "");
+	matches.sort((a, b) => {
+		const aIdx = FAVICON_PATTERNS.indexOf(relativize(a));
+		const bIdx = FAVICON_PATTERNS.indexOf(relativize(b));
+		return (aIdx === -1 ? Infinity : aIdx) - (bIdx === -1 ? Infinity : bIdx);
+	});
+	const iconPath = matches[0];
🤖 Prompt for AI Agents
In `@apps/desktop/src/lib/trpc/routers/projects/utils/favicon-discovery.ts` around
lines 48 - 58, The current selection of iconPath relies on fast-glob results but
fg does not guarantee pattern-priority ordering, so change the logic after the
fg(...) call to pick the highest-priority match based on FAVICON_PATTERNS: build
a lookup of matches, then iterate FAVICON_PATTERNS in order and for each pattern
check which matched paths correspond to it (e.g., using minimatch or by
re-testing the pattern against each match) and choose the first hit as iconPath;
update the code referencing matches[0] to use this prioritized selection while
preserving ignore/cwd/absolute settings from the original fg call.

Comment on lines +91 to +95
// Parse data URL: data:image/png;base64,<data>
const match = dataUrl.match(/^data:image\/(\w+);base64,(.+)$/);
if (!match) {
throw new Error("Invalid data URL format");
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Data URL regex fails for image/svg+xml — SVG uploads will always be rejected.

The regex ^data:image\/(\w+);base64,(.+)$ uses \w+ which matches [a-zA-Z0-9_] only. SVG data URLs have MIME type image/svg+xml, and the + character is not matched by \w. This means any SVG file uploaded via the settings UI will throw "Invalid data URL format".

The file input accepts image/svg+xml, so users can select SVGs but they'll silently fail.

Proposed fix
-	const match = dataUrl.match(/^data:image\/(\w+);base64,(.+)$/);
+	const match = dataUrl.match(/^data:image\/([\w+.-]+);base64,(.+)$/s);
 	if (!match) {
 		throw new Error("Invalid data URL format");
 	}

-	const ext = match[1] === "jpeg" ? "jpg" : match[1];
+	// Normalize MIME subtype to file extension
+	const mimeSubtype = match[1];
+	let ext: string;
+	if (mimeSubtype === "jpeg") {
+		ext = "jpg";
+	} else if (mimeSubtype === "svg+xml") {
+		ext = "svg";
+	} else if (mimeSubtype === "x-icon" || mimeSubtype === "vnd.microsoft.icon") {
+		ext = "ico";
+	} else {
+		ext = mimeSubtype;
+	}
🤖 Prompt for AI Agents
In `@apps/desktop/src/main/lib/project-icons.ts` around lines 91 - 95, The data
URL regex in project-icons.ts (the dataUrl.match(...) call) incorrectly uses \w+
so it rejects MIME types like image/svg+xml; update that regex to accept
characters used in MIME subtype names (e.g., plus, dot, hyphen and
alphanumerics) or use a more permissive token for the subtype (for example match
[a-zA-Z0-9.+-]+ or [^;]+) so image/svg+xml is accepted and SVG uploads no longer
throw "Invalid data URL format".

Comment on lines 46 to +47
const [imageError, setImageError] = useState(false);
const [iconError, setIconError] = useState(false);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

iconError is never reset when iconUrl changes — stale error state will prevent replaced icons from rendering.

If an icon fails to load, iconError is set to true. When the user later replaces the icon (new iconUrl prop), the component won't re-render the <img> because iconError remains true. The fallback stays stuck until the component remounts.

Reset iconError when iconUrl changes:

Proposed fix
 const [imageError, setImageError] = useState(false);
 const [iconError, setIconError] = useState(false);
+
+// Reset error state when icon URL changes
+useEffect(() => {
+  setIconError(false);
+}, [iconUrl]);

(Requires adding useEffect to the import from react.)

🤖 Prompt for AI Agents
In
`@apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectThumbnail/ProjectThumbnail.tsx`
around lines 46 - 47, The iconError state isn't reset when iconUrl changes so a
previously failed icon blocks rendering new icons; add a useEffect that watches
the iconUrl prop and calls setIconError(false) whenever iconUrl changes (also
ensure useEffect is imported from react) so the <img> in ProjectThumbnail can
attempt to load the new icon; reference the iconError state setter setIconError
and the iconUrl prop in your effect.

@Kitenite Kitenite deleted the handle-priyank-pr branch February 16, 2026 00:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant