From 833d3d2d2ed3c40e792868c44a026f609cf1d219 Mon Sep 17 00:00:00 2001 From: Ipriyankrajai Date: Tue, 3 Feb 2026 16:36:15 +0530 Subject: [PATCH 01/11] feat(desktop): implement favicon discovery and icon management for projects - Added `discoverFavicon` utility to locate and retrieve favicons from project directories. - Enhanced `upsertProject` function to trigger favicon discovery for both new and existing projects. - Introduced `setProjectIcon` and `discoverProjectIcon` procedures for managing project icons via TRPC. - Created `IconUploader` component for user-uploaded icons in project settings. - Updated database schema to include `iconUrl` and `iconOverride` fields for projects. - Modified various components to display project icons, including sidebar and project settings. --- .../src/lib/trpc/routers/projects/projects.ts | 106 +++++++- .../projects/utils/favicon-discovery.ts | 67 +++++ .../routers/workspaces/procedures/query.ts | 4 + .../ProjectSettings/ProjectSettings.tsx | 34 ++- .../components/IconUploader/IconUploader.tsx | 250 ++++++++++++++++++ .../components/IconUploader/index.ts | 1 + .../components/IconUploader/utils.ts | 104 ++++++++ .../ProjectSection/ProjectHeader.tsx | 10 + .../ProjectSection/ProjectSection.tsx | 8 + .../ProjectThumbnail/ProjectThumbnail.tsx | 35 ++- .../WorkspaceSidebar/WorkspaceSidebar.tsx | 2 + .../drizzle/0014_add_project_icons.sql | 2 + packages/local-db/drizzle/meta/_journal.json | 7 + packages/local-db/src/schema/schema.ts | 2 + 14 files changed, 628 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/lib/trpc/routers/projects/utils/favicon-discovery.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/IconUploader/IconUploader.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/IconUploader/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/IconUploader/utils.ts create mode 100644 packages/local-db/drizzle/0014_add_project_icons.sql diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index e8db7dc2f5f..6174b6539a7 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -33,6 +33,7 @@ import { sanitizeAuthorPrefix, } from "../workspaces/utils/git"; import { getDefaultProjectColor } from "./utils/colors"; +import { discoverFavicon } from "./utils/favicon-discovery"; import { fetchGitHubOwner, getGitHubAvatarUrl } from "./utils/github"; type Project = SelectProject; @@ -55,7 +56,7 @@ export type OpenNewResult = /** * Creates or updates a project record in the database. * If a project with the same mainRepoPath exists, updates lastOpenedAt. - * Otherwise, creates a new project. + * Otherwise, creates a new project and triggers favicon discovery. */ function upsertProject(mainRepoPath: string, defaultBranch: string): Project { const name = basename(mainRepoPath); @@ -72,6 +73,27 @@ function upsertProject(mainRepoPath: string, defaultBranch: string): Project { .set({ lastOpenedAt: Date.now(), defaultBranch }) .where(eq(projects.id, existing.id)) .run(); + + // Fire-and-forget discovery for existing projects without icons + if (!existing.iconOverride && !existing.iconUrl) { + discoverFavicon(mainRepoPath) + .then((icon) => { + if (icon) { + localDb + .update(projects) + .set({ iconUrl: icon }) + .where(eq(projects.id, existing.id)) + .run(); + console.log( + `[upsertProject] Discovered favicon for existing project: ${existing.id}`, + ); + } + }) + .catch((err) => { + console.error("[upsertProject] Favicon discovery error:", err); + }); + } + return { ...existing, lastOpenedAt: Date.now(), defaultBranch }; } @@ -86,6 +108,23 @@ function upsertProject(mainRepoPath: string, defaultBranch: string): Project { .returning() .get(); + discoverFavicon(mainRepoPath) + .then((icon) => { + if (icon) { + localDb + .update(projects) + .set({ iconUrl: icon }) + .where(eq(projects.id, project.id)) + .run(); + console.log( + `[upsertProject] Discovered favicon for new project: ${project.id}`, + ); + } + }) + .catch((err) => { + console.error("[upsertProject] Favicon discovery error:", err); + }); + return project; } @@ -1027,6 +1066,71 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { prefix: sanitizeAuthorPrefix(authorName), }; }), + + setProjectIcon: publicProcedure + .input( + z.object({ + id: z.string(), + icon: z.string().nullable(), // Base64 data URL or null to remove + }), + ) + .mutation(({ input }) => { + const project = localDb + .select() + .from(projects) + .where(eq(projects.id, input.id)) + .get(); + + if (!project) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Project ${input.id} not found`, + }); + } + + // Update iconOverride field + localDb + .update(projects) + .set({ iconOverride: input.icon }) + .where(eq(projects.id, input.id)) + .run(); + + return { success: true }; + }), + + discoverProjectIcon: publicProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ input }) => { + const project = localDb + .select() + .from(projects) + .where(eq(projects.id, input.id)) + .get(); + + if (!project) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Project ${input.id} not found`, + }); + } + + // Skip if icon already exists + if (project.iconOverride || project.iconUrl) { + return { success: true, found: false, skipped: true }; + } + + const icon = await discoverFavicon(project.mainRepoPath); + + if (icon) { + localDb + .update(projects) + .set({ iconUrl: icon }) + .where(eq(projects.id, input.id)) + .run(); + } + + return { success: true, found: !!icon }; + }), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/projects/utils/favicon-discovery.ts b/apps/desktop/src/lib/trpc/routers/projects/utils/favicon-discovery.ts new file mode 100644 index 00000000000..f819e8daaf7 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/projects/utils/favicon-discovery.ts @@ -0,0 +1,67 @@ +import { readFileSync, statSync } from "node:fs"; +import fg from "fast-glob"; + +const FAVICON_GLOB = "**/favicon.{ico,png,svg,jpg,jpeg,webp}"; + +// Maximum file size to read (prevent memory issues) +const MAX_FILE_SIZE = 500 * 1024; // 500KB + +/** + * Discover favicon in project directory. + * + * @param repoPath - Path to the git repository + * @returns Base64 data URL or null if not found + */ +export async function discoverFavicon( + repoPath: string, +): Promise { + try { + const matches = await fg(FAVICON_GLOB, { + cwd: repoPath, + absolute: true, + onlyFiles: true, + dot: false, // Ignore dotfiles/directories + }); + + if (matches.length === 0) return null; + + // Pick shortest path (closest to project root) + const shortest = matches.sort((a, b) => a.length - b.length)[0]; + + // Check file size + const stats = statSync(shortest); + if (stats.size > MAX_FILE_SIZE) { + console.log( + `[favicon-discovery] File too large: ${shortest} (${stats.size} bytes)`, + ); + return null; + } + + // Read and convert to base64 data URL + const buffer = readFileSync(shortest); + const base64 = buffer.toString("base64"); + const ext = shortest.split(".").pop()?.toLowerCase(); + const mime = getMimeType(ext); + + console.log( + `[favicon-discovery] Found favicon: ${shortest} (${stats.size} bytes)`, + ); + + return `data:${mime};base64,${base64}`; + } catch (error) { + console.error("[favicon-discovery] Error:", error); + return null; + } +} + +function getMimeType(ext: string | undefined): string { + const mimes: Record = { + ico: "image/x-icon", + png: "image/png", + svg: "image/svg+xml", + jpg: "image/jpeg", + jpeg: "image/jpeg", + webp: "image/webp", + }; + return mimes[ext ?? ""] ?? "image/png"; +} diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts index 86404e18a56..ba65535a5a9 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts @@ -159,6 +159,8 @@ export const createQueryProcedures = () => { tabOrder: number; githubOwner: string | null; mainRepoPath: string; + iconUrl: string | null; + iconOverride: string | null; }; workspaces: Array<{ id: string; @@ -187,6 +189,8 @@ export const createQueryProcedures = () => { tabOrder: project.tabOrder!, githubOwner: project.githubOwner ?? null, mainRepoPath: project.mainRepoPath, + iconUrl: project.iconUrl ?? null, + iconOverride: project.iconOverride ?? null, }, workspaces: [], }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx index c61362bb7d1..6af4850f4a3 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx @@ -1,6 +1,11 @@ -import { HiOutlineCog6Tooth, HiOutlineFolder } from "react-icons/hi2"; +import { + HiOutlineCog6Tooth, + HiOutlineFolder, + HiOutlinePhoto, +} from "react-icons/hi2"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { ClickablePath } from "../../../../components/ClickablePath"; +import { IconUploader } from "./components/IconUploader"; import { ScriptsEditor } from "./components/ScriptsEditor"; interface ProjectSettingsProps { @@ -8,10 +13,18 @@ interface ProjectSettingsProps { } export function ProjectSettings({ projectId }: ProjectSettingsProps) { + const utils = electronTrpc.useUtils(); const { data: project } = electronTrpc.projects.get.useQuery({ id: projectId, }); + const setIcon = electronTrpc.projects.setProjectIcon.useMutation({ + onSuccess: () => { + utils.projects.get.invalidate({ id: projectId }); + utils.workspaces.getAllGrouped.invalidate(); + }, + }); + if (!project) { return null; } @@ -23,6 +36,25 @@ export function ProjectSettings({ projectId }: ProjectSettingsProps) {
+
+

+ + Project Icon +

+

+ Upload a custom icon or use the auto-discovered favicon. +

+ setIcon.mutate({ id: projectId, icon })} + isPending={setIcon.isPending} + /> +
+

Name

{project.name}

diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/IconUploader/IconUploader.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/IconUploader/IconUploader.tsx new file mode 100644 index 00000000000..e3c287f8d8c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/IconUploader/IconUploader.tsx @@ -0,0 +1,250 @@ +import { Button } from "@superset/ui/button"; +import { cn } from "@superset/ui/utils"; +import { useCallback, useRef, useState } from "react"; +import { HiOutlinePhoto, HiXMark } from "react-icons/hi2"; +import { LuUpload } from "react-icons/lu"; +import { processIconFile } from "./utils"; + +interface IconUploaderProps { + projectId: string; + projectName: string; + iconUrl: string | null; // Auto-discovered + iconOverride: string | null; // User-uploaded + githubOwner: string | null; + onIconChange: (icon: string | null) => void; + isPending?: boolean; +} + +function getGitHubAvatarUrl(owner: string): string { + return `https://github.com/${owner}.png?size=64`; +} + +export function IconUploader({ + projectName, + iconUrl, + iconOverride, + githubOwner, + onIconChange, + isPending = false, +}: IconUploaderProps) { + const fileInputRef = useRef(null); + const [isDragOver, setIsDragOver] = useState(false); + const [error, setError] = useState(null); + const [isProcessing, setIsProcessing] = useState(false); + + // Determine which icon to show + const effectiveIcon = iconOverride || iconUrl; + const hasCustomIcon = !!iconOverride; + const hasDiscoveredIcon = !!iconUrl && !iconOverride; + + const handleFileSelect = useCallback( + async (file: File) => { + setError(null); + setIsProcessing(true); + + try { + const processedIcon = await processIconFile(file); + onIconChange(processedIcon); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to process file"); + } finally { + setIsProcessing(false); + } + }, + [onIconChange], + ); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(true); + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + }, []); + + const handleDrop = useCallback( + async (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + + const files = Array.from(e.dataTransfer.files); + const imageFile = files.find((f) => f.type.startsWith("image/")); + + if (imageFile) { + await handleFileSelect(imageFile); + } + }, + [handleFileSelect], + ); + + const handleInputChange = useCallback( + async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + await handleFileSelect(file); + } + // Reset input so same file can be selected again + e.target.value = ""; + }, + [handleFileSelect], + ); + + const handleRemoveIcon = useCallback(() => { + setError(null); + onIconChange(null); + }, [onIconChange]); + + const handleClickUpload = () => { + fileInputRef.current?.click(); + }; + + const firstLetter = projectName.charAt(0).toUpperCase(); + const isLoading = isPending || isProcessing; + + return ( +
+ {/* Current Icon Preview */} +
+ {/* Preview */} +
+ {effectiveIcon ? ( + {`${projectName} + ) : githubOwner ? ( + {`${projectName} + ) : ( + + {firstLetter} + + )} +
+ + {/* Info and Actions */} +
+
+ {hasCustomIcon && ( + + Custom icon uploaded + + )} + {hasDiscoveredIcon && ( + + Auto-discovered from project + + )} + {!effectiveIcon && githubOwner && ( + Using GitHub avatar + )} + {!effectiveIcon && !githubOwner && ( + Using default icon + )} +
+ +
+ + + {hasCustomIcon && ( + + )} +
+
+
+ + {/* Drop Zone */} + {/* biome-ignore lint/a11y/useSemanticElements: Drop zone wrapper for drag-and-drop functionality */} +
{ + if (e.key === "Enter" || e.key === " ") { + handleClickUpload(); + } + }} + className={cn( + "relative border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors", + isDragOver + ? "border-primary bg-primary/5" + : "border-border hover:border-muted-foreground/50", + isLoading && "opacity-50 pointer-events-none", + )} + > +
+ +
+ Click to upload{" "} + or drag and drop +
+
+ PNG, JPG, GIF, SVG, WebP, or ICO (max 500KB) +
+
+ + {isDragOver && ( +
+
+ + Drop to upload +
+
+ )} + + {isLoading && ( +
+
Processing...
+
+ )} +
+ + {/* Hidden file input */} + + + {/* Error message */} + {error &&
{error}
} +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/IconUploader/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/IconUploader/index.ts new file mode 100644 index 00000000000..1eddc2c5e5a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/IconUploader/index.ts @@ -0,0 +1 @@ +export { IconUploader } from "./IconUploader"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/IconUploader/utils.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/IconUploader/utils.ts new file mode 100644 index 00000000000..75b71512d37 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/IconUploader/utils.ts @@ -0,0 +1,104 @@ +const ALLOWED_TYPES = [ + "image/png", + "image/jpeg", + "image/gif", + "image/svg+xml", + "image/webp", + "image/x-icon", +]; + +const MAX_FILE_SIZE = 500 * 1024; // 500KB +const MAX_DIMENSION = 128; + +/** + * Validates and processes an icon file, returning a base64 data URL. + * Resizes images to 128x128 max while maintaining aspect ratio. + */ +export async function processIconFile(file: File): Promise { + // Validate file type + if (!ALLOWED_TYPES.includes(file.type)) { + throw new Error( + "Unsupported file type. Please use PNG, JPG, GIF, SVG, WebP, or ICO.", + ); + } + + // Validate file size + if (file.size > MAX_FILE_SIZE) { + throw new Error( + `File too large. Maximum size is ${MAX_FILE_SIZE / 1024}KB.`, + ); + } + + // Read file as data URL + const dataUrl = await readFileAsDataUrl(file); + + // SVG files don't need resizing + if (file.type === "image/svg+xml") { + return dataUrl; + } + + // ICO files are typically small enough + if (file.type === "image/x-icon") { + return dataUrl; + } + + // Resize raster images + return resizeImage(dataUrl, MAX_DIMENSION); +} + +function readFileAsDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(new Error("Failed to read file")); + reader.readAsDataURL(file); + }); +} + +/** + * Resizes an image to fit within maxDimension while maintaining aspect ratio. + * Uses canvas for resizing and returns a PNG data URL. + */ +export function resizeImage( + dataUrl: string, + maxDimension: number, +): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + // Calculate new dimensions maintaining aspect ratio + let { width, height } = img; + + if (width > maxDimension || height > maxDimension) { + if (width > height) { + height = Math.round((height / width) * maxDimension); + width = maxDimension; + } else { + width = Math.round((width / height) * maxDimension); + height = maxDimension; + } + } + + // Create canvas and draw resized image + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext("2d"); + if (!ctx) { + reject(new Error("Failed to get canvas context")); + return; + } + + // Use high-quality interpolation + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = "high"; + ctx.drawImage(img, 0, 0, width, height); + + // Return as PNG data URL + resolve(canvas.toDataURL("image/png")); + }; + img.onerror = () => reject(new Error("Failed to load image")); + img.src = dataUrl; + }); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx index 197e01342f8..94ce1a612ab 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx @@ -40,6 +40,8 @@ interface ProjectHeaderProps { projectColor: string; githubOwner: string | null; mainRepoPath: string; + iconUrl?: string | null; + iconOverride?: string | null; /** Whether the project section is collapsed (workspaces hidden) */ isCollapsed: boolean; /** Whether the sidebar is in collapsed mode (icon-only view) */ @@ -55,6 +57,8 @@ export function ProjectHeader({ projectColor, githubOwner, mainRepoPath, + iconUrl, + iconOverride, isCollapsed, isSidebarCollapsed = false, onToggleCollapse, @@ -197,6 +201,8 @@ export function ProjectHeader({ projectName={projectName} projectColor={projectColor} githubOwner={githubOwner} + iconUrl={iconUrl} + iconOverride={iconOverride} /> @@ -270,6 +276,8 @@ export function ProjectHeader({ projectName={projectName} projectColor={projectColor} githubOwner={githubOwner} + iconUrl={iconUrl} + iconOverride={iconOverride} /> {projectName} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx index 3a28eac2a80..2371b06d432 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx @@ -28,6 +28,8 @@ interface ProjectSectionProps { projectColor: string; githubOwner: string | null; mainRepoPath: string; + iconUrl?: string | null; + iconOverride?: string | null; workspaces: Workspace[]; /** Base index for keyboard shortcuts (0-based) */ shortcutBaseIndex: number; @@ -43,6 +45,8 @@ export function ProjectSection({ projectColor, githubOwner, mainRepoPath, + iconUrl, + iconOverride, workspaces, shortcutBaseIndex, index, @@ -124,6 +128,8 @@ export function ProjectSection({ projectColor={projectColor} githubOwner={githubOwner} mainRepoPath={mainRepoPath} + iconUrl={iconUrl} + iconOverride={iconOverride} isCollapsed={isCollapsed} isSidebarCollapsed={isSidebarCollapsed} onToggleCollapse={() => toggleProjectCollapsed(projectId)} @@ -180,6 +186,8 @@ export function ProjectSection({ projectColor={projectColor} githubOwner={githubOwner} mainRepoPath={mainRepoPath} + iconUrl={iconUrl} + iconOverride={iconOverride} isCollapsed={isCollapsed} isSidebarCollapsed={isSidebarCollapsed} onToggleCollapse={() => toggleProjectCollapsed(projectId)} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectThumbnail/ProjectThumbnail.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectThumbnail/ProjectThumbnail.tsx index 9dcc8f2b342..dd86443cc02 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectThumbnail/ProjectThumbnail.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectThumbnail/ProjectThumbnail.tsx @@ -8,6 +8,8 @@ interface ProjectThumbnailProps { projectName: string; projectColor: string; githubOwner: string | null; + iconUrl?: string | null; // Auto-discovered favicon + iconOverride?: string | null; // User-uploaded icon (takes priority) className?: string; } @@ -37,15 +39,23 @@ export function ProjectThumbnail({ projectName, projectColor, githubOwner, + iconUrl, + iconOverride, className, }: ProjectThumbnailProps) { const [imageError, setImageError] = useState(false); + const [iconError, setIconError] = useState(false); + // Determine which icon to show + const effectiveIcon = iconOverride || iconUrl; + + // Only fetch GitHub avatar if no icon is set const { data: avatarData } = electronTrpc.projects.getGitHubAvatar.useQuery( { id: projectId }, { staleTime: 1000 * 60 * 5, refetchOnWindowFocus: false, + enabled: !effectiveIcon, }, ); @@ -62,7 +72,28 @@ export function ProjectThumbnail({ ? { borderColor: hexToRgba(projectColor, 0.6) } : undefined; - // Show GitHub avatar if available + // Priority 1: Show project icon (override or discovered) + if (effectiveIcon && !iconError) { + return ( +
+ {`${projectName} setIconError(true)} + /> +
+ ); + } + + // Priority 2: Show GitHub avatar if available if (owner && !imageError) { return (
statement-breakpoint +ALTER TABLE `projects` ADD `icon_override` text; diff --git a/packages/local-db/drizzle/meta/_journal.json b/packages/local-db/drizzle/meta/_journal.json index 6e8799acfe4..8807bcb4863 100644 --- a/packages/local-db/drizzle/meta/_journal.json +++ b/packages/local-db/drizzle/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1769500000000, "tag": "0013_add_auto_apply_default_preset", "breakpoints": true + }, + { + "idx": 14, + "version": "6", + "when": 1769600000000, + "tag": "0014_add_project_icons", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/local-db/src/schema/schema.ts b/packages/local-db/src/schema/schema.ts index 6a968a7aefe..8e8f22be28c 100644 --- a/packages/local-db/src/schema/schema.ts +++ b/packages/local-db/src/schema/schema.ts @@ -34,6 +34,8 @@ export const projects = sqliteTable( }), defaultBranch: text("default_branch"), githubOwner: text("github_owner"), + iconUrl: text("icon_url"), // Auto-discovered favicon (base64 data URL) + iconOverride: text("icon_override"), // User-uploaded icon (base64 data URL) }, (table) => [ index("projects_main_repo_path_idx").on(table.mainRepoPath), From b759a2f34e8c5c3d08487512dc83c2c0191a5a32 Mon Sep 17 00:00:00 2001 From: Ipriyankrajai Date: Tue, 3 Feb 2026 16:46:41 +0530 Subject: [PATCH 02/11] feat(database): add icon_url and icon_override fields to projects table - Introduced new fields `icon_url` and `icon_override` in the `projects` table to support project icon management. - Updated migration journal and created a snapshot for versioning. --- ...t_icons.sql => 0018_add_project_icons.sql} | 2 +- .../local-db/drizzle/meta/0018_snapshot.json | 1078 +++++++++++++++++ packages/local-db/drizzle/meta/_journal.json | 7 + 3 files changed, 1086 insertions(+), 1 deletion(-) rename packages/local-db/drizzle/{0014_add_project_icons.sql => 0018_add_project_icons.sql} (58%) create mode 100644 packages/local-db/drizzle/meta/0018_snapshot.json diff --git a/packages/local-db/drizzle/0014_add_project_icons.sql b/packages/local-db/drizzle/0018_add_project_icons.sql similarity index 58% rename from packages/local-db/drizzle/0014_add_project_icons.sql rename to packages/local-db/drizzle/0018_add_project_icons.sql index 295fb7baa15..ac85c027e56 100644 --- a/packages/local-db/drizzle/0014_add_project_icons.sql +++ b/packages/local-db/drizzle/0018_add_project_icons.sql @@ -1,2 +1,2 @@ ALTER TABLE `projects` ADD `icon_url` text;--> statement-breakpoint -ALTER TABLE `projects` ADD `icon_override` text; +ALTER TABLE `projects` ADD `icon_override` text; \ No newline at end of file diff --git a/packages/local-db/drizzle/meta/0018_snapshot.json b/packages/local-db/drizzle/meta/0018_snapshot.json new file mode 100644 index 00000000000..b8f4e7a681c --- /dev/null +++ b/packages/local-db/drizzle/meta/0018_snapshot.json @@ -0,0 +1,1078 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "c0e04d13-ff21-4de1-9fbb-4e1411285e9c", + "prevId": "2795f5b8-dabc-492f-9510-1d29005705a0", + "tables": { + "organization_members": { + "name": "organization_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organization_members_organization_id_idx": { + "name": "organization_members_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "organization_members_user_id_idx": { + "name": "organization_members_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "organization_members_organization_id_organizations_id_fk": { + "name": "organization_members_organization_id_organizations_id_fk", + "tableFrom": "organization_members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_members_user_id_users_id_fk": { + "name": "organization_members_user_id_users_id_fk", + "tableFrom": "organization_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "clerk_org_id": { + "name": "clerk_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "github_org": { + "name": "github_org", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organizations_clerk_org_id_unique": { + "name": "organizations_clerk_org_id_unique", + "columns": [ + "clerk_org_id" + ], + "isUnique": true + }, + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "organizations_clerk_org_id_idx": { + "name": "organizations_clerk_org_id_idx", + "columns": [ + "clerk_org_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "main_repo_path": { + "name": "main_repo_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_toast_dismissed": { + "name": "config_toast_dismissed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_owner": { + "name": "github_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_mode": { + "name": "branch_prefix_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_custom": { + "name": "branch_prefix_custom", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_override": { + "name": "icon_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "projects_main_repo_path_idx": { + "name": "projects_main_repo_path_idx", + "columns": [ + "main_repo_path" + ], + "isUnique": false + }, + "projects_last_opened_at_idx": { + "name": "projects_last_opened_at_idx", + "columns": [ + "last_opened_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_app": { + "name": "last_used_app", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_presets": { + "name": "terminal_presets", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_presets_initialized": { + "name": "terminal_presets_initialized", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "selected_ringtone_id": { + "name": "selected_ringtone_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_on_quit": { + "name": "confirm_on_quit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_link_behavior": { + "name": "terminal_link_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "persist_terminal": { + "name": "persist_terminal", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "auto_apply_default_preset": { + "name": "auto_apply_default_preset", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_mode": { + "name": "branch_prefix_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_custom": { + "name": "branch_prefix_custom", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notification_sounds_muted": { + "name": "notification_sounds_muted", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_color": { + "name": "status_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_type": { + "name": "status_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_position": { + "name": "status_position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "estimate": { + "name": "estimate", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "labels": { + "name": "labels", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_provider": { + "name": "external_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tasks_slug_unique": { + "name": "tasks_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "tasks_slug_idx": { + "name": "tasks_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "tasks_organization_id_idx": { + "name": "tasks_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "tasks_assignee_id_idx": { + "name": "tasks_assignee_id_idx", + "columns": [ + "assignee_id" + ], + "isUnique": false + }, + "tasks_status_idx": { + "name": "tasks_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "tasks_created_at_idx": { + "name": "tasks_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tasks_organization_id_organizations_id_fk": { + "name": "tasks_organization_id_organizations_id_fk", + "tableFrom": "tasks", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_assignee_id_users_id_fk": { + "name": "tasks_assignee_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_creator_id_users_id_fk": { + "name": "tasks_creator_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "clerk_id": { + "name": "clerk_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_clerk_id_unique": { + "name": "users_clerk_id_unique", + "columns": [ + "clerk_id" + ], + "isUnique": true + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + "email" + ], + "isUnique": false + }, + "users_clerk_id_idx": { + "name": "users_clerk_id_idx", + "columns": [ + "clerk_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worktree_id": { + "name": "worktree_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_unread": { + "name": "is_unread", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "is_unnamed": { + "name": "is_unnamed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "deleting_at": { + "name": "deleting_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workspaces_project_id_idx": { + "name": "workspaces_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "workspaces_worktree_id_idx": { + "name": "workspaces_worktree_id_idx", + "columns": [ + "worktree_id" + ], + "isUnique": false + }, + "workspaces_last_opened_at_idx": { + "name": "workspaces_last_opened_at_idx", + "columns": [ + "last_opened_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_project_id_projects_id_fk": { + "name": "workspaces_project_id_projects_id_fk", + "tableFrom": "workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_worktree_id_worktrees_id_fk": { + "name": "workspaces_worktree_id_worktrees_id_fk", + "tableFrom": "workspaces", + "tableTo": "worktrees", + "columnsFrom": [ + "worktree_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_status": { + "name": "git_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_status": { + "name": "github_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "worktrees_project_id_idx": { + "name": "worktrees_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "worktrees_branch_idx": { + "name": "worktrees_branch_idx", + "columns": [ + "branch" + ], + "isUnique": false + } + }, + "foreignKeys": { + "worktrees_project_id_projects_id_fk": { + "name": "worktrees_project_id_projects_id_fk", + "tableFrom": "worktrees", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/local-db/drizzle/meta/_journal.json b/packages/local-db/drizzle/meta/_journal.json index ccb9d33bc4d..a99b772fc3a 100644 --- a/packages/local-db/drizzle/meta/_journal.json +++ b/packages/local-db/drizzle/meta/_journal.json @@ -127,6 +127,13 @@ "when": 1770103994672, "tag": "0017_add_is_unnamed_to_workspaces", "breakpoints": true + }, + { + "idx": 18, + "version": "6", + "when": 1770117318144, + "tag": "0018_add_project_icons", + "breakpoints": true } ] } \ No newline at end of file From e9b5739a7f8441295c429cfa9fef64096dbdb322 Mon Sep 17 00:00:00 2001 From: Ipriyankrajai Date: Tue, 3 Feb 2026 16:51:42 +0530 Subject: [PATCH 03/11] chore: bump version of @superset/desktop to 0.0.66 in bun.lock --- bun.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index 589b5f79270..389bfcbddd2 100644 --- a/bun.lock +++ b/bun.lock @@ -131,7 +131,7 @@ }, "apps/desktop": { "name": "@superset/desktop", - "version": "0.0.65", + "version": "0.0.66", "dependencies": { "@better-auth/stripe": "1.4.17", "@dnd-kit/core": "^6.3.1", From e4c920f9e2fce72d3cfdbccd02574e38c7107111 Mon Sep 17 00:00:00 2001 From: Ipriyankrajai Date: Tue, 3 Feb 2026 17:04:51 +0530 Subject: [PATCH 04/11] feat: add command for resolving PR comments in GitHub - Introduced a new markdown file with instructions on how to resolve active comments on pull requests using the `gh` CLI. - Included commands to enumerate comments, view details, and mark comments as resolved. --- .claude/commands/respond-to-pr-comments.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .claude/commands/respond-to-pr-comments.md diff --git a/.claude/commands/respond-to-pr-comments.md b/.claude/commands/respond-to-pr-comments.md new file mode 100644 index 00000000000..f7c1edeb09d --- /dev/null +++ b/.claude/commands/respond-to-pr-comments.md @@ -0,0 +1,14 @@ +Task is to resolve all active comments on PR in GitHub. + +First enumerate them, and then let's discuss each one. Wait for direction from me before moving forward on a fix. + +Use `gh` CLI to perform operations + +# Get PR number from current branch +gh pr view --json number --jq .number + +# Get all comments +gh pr view --json comments --jq '.comments[] | {id: .id, body: .body, author: .author.login}' + +# Mark comment as resolved +gh pr comment resolve \ No newline at end of file From 8877efea0142713228de0c9fe16267e80a14d208 Mon Sep 17 00:00:00 2001 From: Ipriyankrajai Date: Tue, 3 Feb 2026 17:05:00 +0530 Subject: [PATCH 05/11] feat(project-settings): add error handling for icon update failures - Implemented error logging in the icon update process within ProjectSettings component to improve debugging and user feedback. --- .../$projectId/components/ProjectSettings/ProjectSettings.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx index c56f5461f33..16e407d43c6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx @@ -112,6 +112,9 @@ export function ProjectSettings({ projectId }: ProjectSettingsProps) { utils.projects.get.invalidate({ id: projectId }); utils.workspaces.getAllGrouped.invalidate(); }, + onError: (err) => { + console.error("[project-settings/setIcon] Failed to update icon:", err); + }, }); if (!project) { From 0f461439b37600ceed3ccd517ae738e652ba89e1 Mon Sep 17 00:00:00 2001 From: Ipriyankrajai Date: Tue, 3 Feb 2026 17:13:59 +0530 Subject: [PATCH 06/11] fix(ProjectThumbnail): reset error state on icon change - Added a useRef hook to track the previous icon and reset the error state when the icon source changes. - Removed unnecessary flex-shrink class from the ProjectThumbnail component for improved layout consistency. --- .../ProjectThumbnail/ProjectThumbnail.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectThumbnail/ProjectThumbnail.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectThumbnail/ProjectThumbnail.tsx index dd86443cc02..b04ee96ec45 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectThumbnail/ProjectThumbnail.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectThumbnail/ProjectThumbnail.tsx @@ -1,5 +1,5 @@ import { cn } from "@superset/ui/utils"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { PROJECT_COLOR_DEFAULT } from "shared/constants/project-colors"; @@ -49,6 +49,13 @@ export function ProjectThumbnail({ // Determine which icon to show const effectiveIcon = iconOverride || iconUrl; + // Reset error state when icon source changes + const prevIconRef = useRef(effectiveIcon); + if (prevIconRef.current !== effectiveIcon) { + setIconError(false); + prevIconRef.current = effectiveIcon; + } + // Only fetch GitHub avatar if no icon is set const { data: avatarData } = electronTrpc.projects.getGitHubAvatar.useQuery( { id: projectId }, @@ -77,7 +84,7 @@ export function ProjectThumbnail({ return (
Date: Tue, 3 Feb 2026 17:14:47 +0530 Subject: [PATCH 07/11] feat(projects): enhance icon validation for project settings - Added regex validation for base64 image data URLs and a maximum length constraint for project icons. - Updated the input schema for project creation to enforce these new validation rules, ensuring better error handling and user feedback. --- .../desktop/src/lib/trpc/routers/projects/projects.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index b80ff9877cd..fbd28e4d8fd 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -223,6 +223,10 @@ async function ensureMainWorkspace(project: Project): Promise { // Allows most valid Git repo names while avoiding path traversal characters const SAFE_REPO_NAME_REGEX = /^[a-zA-Z0-9._\- ]+$/; +// Icon data URL validation +const ICON_DATA_URL_REGEX = /^data:image\/[a-z0-9.+-]+;base64,/i; +const MAX_ICON_DATA_URL_LENGTH = 512 * 1024; + /** * Extracts and validates a repository name from a git URL. * Handles HTTP/HTTPS URLs, SSH-style URLs (git@host:user/repo), and edge cases. @@ -1080,7 +1084,12 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { .input( z.object({ id: z.string(), - icon: z.string().nullable(), // Base64 data URL or null to remove + icon: z + .string() + .trim() + .max(MAX_ICON_DATA_URL_LENGTH, "Icon data URL is too large") + .regex(ICON_DATA_URL_REGEX, "Icon must be a base64 image data URL") + .nullable(), // Base64 data URL or null to remove }), ) .mutation(({ input }) => { From a8e0d668fbbb4c02e326c76060425e6e3b0b9487 Mon Sep 17 00:00:00 2001 From: Ipriyankrajai Date: Tue, 3 Feb 2026 17:20:05 +0530 Subject: [PATCH 08/11] fix(ProjectSettings): improve error handling for icon update failures - Added toast notification to display error messages when icon update fails, enhancing user feedback and debugging capabilities. --- .../$projectId/components/ProjectSettings/ProjectSettings.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx index 16e407d43c6..9ced12d24bb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx @@ -8,6 +8,7 @@ import { SelectTrigger, SelectValue, } from "@superset/ui/select"; +import { toast } from "@superset/ui/sonner"; import { useEffect, useState } from "react"; import { HiOutlineCog6Tooth, @@ -114,6 +115,7 @@ export function ProjectSettings({ projectId }: ProjectSettingsProps) { }, onError: (err) => { console.error("[project-settings/setIcon] Failed to update icon:", err); + toast.error(`Failed to update icon: ${err.message}`); }, }); From 11e35ef84133a569d3d32fa149727dc1e584fea6 Mon Sep 17 00:00:00 2001 From: Priyank Rajai Date: Tue, 3 Feb 2026 23:02:17 +0530 Subject: [PATCH 09/11] Delete .claude/commands/respond-to-pr-comments.md --- .claude/commands/respond-to-pr-comments.md | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 .claude/commands/respond-to-pr-comments.md diff --git a/.claude/commands/respond-to-pr-comments.md b/.claude/commands/respond-to-pr-comments.md deleted file mode 100644 index f7c1edeb09d..00000000000 --- a/.claude/commands/respond-to-pr-comments.md +++ /dev/null @@ -1,14 +0,0 @@ -Task is to resolve all active comments on PR in GitHub. - -First enumerate them, and then let's discuss each one. Wait for direction from me before moving forward on a fix. - -Use `gh` CLI to perform operations - -# Get PR number from current branch -gh pr view --json number --jq .number - -# Get all comments -gh pr view --json comments --jq '.comments[] | {id: .id, body: .body, author: .author.login}' - -# Mark comment as resolved -gh pr comment resolve \ No newline at end of file From d6aeafa06c364caab6dce9db491849031f856427 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 3 Feb 2026 09:26:43 -0800 Subject: [PATCH 10/11] cleanup: remove unnecessary code and fix data patterns in favicon PR - Remove unrelated .claude/commands/respond-to-pr-comments.md - Tighten ICON_DATA_URL_REGEX to require non-empty base64 payload - Extract duplicated favicon discovery logic into triggerFaviconDiscovery helper - Add favicon discovery to cloneRepo path (was missing) - Remove unused projectId prop from IconUploader component - Remove redundant comments across all changed files - Consolidate SVG/ICO early-return in processIconFile utils --- .../src/lib/trpc/routers/projects/projects.ts | 80 ++++++++++--------- .../projects/utils/favicon-discovery.ts | 12 +-- .../ProjectSettings/ProjectSettings.tsx | 1 - .../components/IconUploader/IconUploader.tsx | 11 +-- .../components/IconUploader/utils.ts | 20 +---- .../ProjectThumbnail/ProjectThumbnail.tsx | 10 +-- packages/local-db/src/schema/schema.ts | 4 +- 7 files changed, 53 insertions(+), 85 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index fbd28e4d8fd..3e7b1fec01b 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -54,6 +54,35 @@ export type OpenNewResult = | OpenNewNeedsGitInit | OpenNewError; +/** + * Fire-and-forget favicon discovery for a project. + * Finds a favicon in the repo directory and stores it as a base64 data URL. + */ +function triggerFaviconDiscovery({ + projectId, + repoPath, +}: { + projectId: string; + repoPath: string; +}): void { + discoverFavicon(repoPath) + .then((icon) => { + if (icon) { + localDb + .update(projects) + .set({ iconUrl: icon }) + .where(eq(projects.id, projectId)) + .run(); + console.log( + `[upsertProject] Discovered favicon for project: ${projectId}`, + ); + } + }) + .catch((err) => { + console.error("[upsertProject] Favicon discovery error:", err); + }); +} + /** * Creates or updates a project record in the database. * If a project with the same mainRepoPath exists, updates lastOpenedAt. @@ -75,24 +104,11 @@ function upsertProject(mainRepoPath: string, defaultBranch: string): Project { .where(eq(projects.id, existing.id)) .run(); - // Fire-and-forget discovery for existing projects without icons if (!existing.iconOverride && !existing.iconUrl) { - discoverFavicon(mainRepoPath) - .then((icon) => { - if (icon) { - localDb - .update(projects) - .set({ iconUrl: icon }) - .where(eq(projects.id, existing.id)) - .run(); - console.log( - `[upsertProject] Discovered favicon for existing project: ${existing.id}`, - ); - } - }) - .catch((err) => { - console.error("[upsertProject] Favicon discovery error:", err); - }); + triggerFaviconDiscovery({ + projectId: existing.id, + repoPath: mainRepoPath, + }); } return { ...existing, lastOpenedAt: Date.now(), defaultBranch }; @@ -109,22 +125,7 @@ function upsertProject(mainRepoPath: string, defaultBranch: string): Project { .returning() .get(); - discoverFavicon(mainRepoPath) - .then((icon) => { - if (icon) { - localDb - .update(projects) - .set({ iconUrl: icon }) - .where(eq(projects.id, project.id)) - .run(); - console.log( - `[upsertProject] Discovered favicon for new project: ${project.id}`, - ); - } - }) - .catch((err) => { - console.error("[upsertProject] Favicon discovery error:", err); - }); + triggerFaviconDiscovery({ projectId: project.id, repoPath: mainRepoPath }); return project; } @@ -223,8 +224,8 @@ async function ensureMainWorkspace(project: Project): Promise { // Allows most valid Git repo names while avoiding path traversal characters const SAFE_REPO_NAME_REGEX = /^[a-zA-Z0-9._\- ]+$/; -// Icon data URL validation -const ICON_DATA_URL_REGEX = /^data:image\/[a-z0-9.+-]+;base64,/i; +const ICON_DATA_URL_REGEX = + /^data:image\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=]+$/i; const MAX_ICON_DATA_URL_LENGTH = 512 * 1024; /** @@ -756,7 +757,6 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { const git = simpleGit(); await git.clone(input.url, clonePath); - // Create new project const name = basename(clonePath); const defaultBranch = await getDefaultBranch(clonePath); const project = localDb @@ -770,7 +770,11 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { .returning() .get(); - // Auto-create main workspace if it doesn't exist + triggerFaviconDiscovery({ + projectId: project.id, + repoPath: clonePath, + }); + await ensureMainWorkspace(project); track("project_opened", { @@ -1106,7 +1110,6 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { }); } - // Update iconOverride field localDb .update(projects) .set({ iconOverride: input.icon }) @@ -1132,7 +1135,6 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { }); } - // Skip if icon already exists if (project.iconOverride || project.iconUrl) { return { success: true, found: false, skipped: true }; } diff --git a/apps/desktop/src/lib/trpc/routers/projects/utils/favicon-discovery.ts b/apps/desktop/src/lib/trpc/routers/projects/utils/favicon-discovery.ts index f819e8daaf7..62ac13fbb1b 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/utils/favicon-discovery.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/utils/favicon-discovery.ts @@ -3,14 +3,11 @@ import fg from "fast-glob"; const FAVICON_GLOB = "**/favicon.{ico,png,svg,jpg,jpeg,webp}"; -// Maximum file size to read (prevent memory issues) const MAX_FILE_SIZE = 500 * 1024; // 500KB /** - * Discover favicon in project directory. - * - * @param repoPath - Path to the git repository - * @returns Base64 data URL or null if not found + * Discovers a favicon in the project directory and returns it as a base64 data URL. + * Picks the favicon closest to the project root if multiple are found. */ export async function discoverFavicon( repoPath: string, @@ -20,15 +17,13 @@ export async function discoverFavicon( cwd: repoPath, absolute: true, onlyFiles: true, - dot: false, // Ignore dotfiles/directories + dot: false, }); if (matches.length === 0) return null; - // Pick shortest path (closest to project root) const shortest = matches.sort((a, b) => a.length - b.length)[0]; - // Check file size const stats = statSync(shortest); if (stats.size > MAX_FILE_SIZE) { console.log( @@ -37,7 +32,6 @@ export async function discoverFavicon( return null; } - // Read and convert to base64 data URL const buffer = readFileSync(shortest); const base64 = buffer.toString("base64"); const ext = shortest.split(".").pop()?.toLowerCase(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx index 9ced12d24bb..b210eae7cc8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx @@ -142,7 +142,6 @@ export function ProjectSettings({ projectId }: ProjectSettingsProps) { Upload a custom icon or use the auto-discovered favicon.

void; isPending?: boolean; @@ -32,7 +31,6 @@ export function IconUploader({ const [error, setError] = useState(null); const [isProcessing, setIsProcessing] = useState(false); - // Determine which icon to show const effectiveIcon = iconOverride || iconUrl; const hasCustomIcon = !!iconOverride; const hasDiscoveredIcon = !!iconUrl && !iconOverride; @@ -108,9 +106,7 @@ export function IconUploader({ return (
- {/* Current Icon Preview */}
- {/* Preview */}
- {/* Info and Actions */}
{hasCustomIcon && ( @@ -234,7 +229,6 @@ export function IconUploader({ )}
- {/* Hidden file input */} - {/* Error message */} {error &&
{error}
}
); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/IconUploader/utils.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/IconUploader/utils.ts index 75b71512d37..94500a272af 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/IconUploader/utils.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/IconUploader/utils.ts @@ -12,37 +12,27 @@ const MAX_DIMENSION = 128; /** * Validates and processes an icon file, returning a base64 data URL. - * Resizes images to 128x128 max while maintaining aspect ratio. + * Resizes raster images to 128x128 max while maintaining aspect ratio. */ export async function processIconFile(file: File): Promise { - // Validate file type if (!ALLOWED_TYPES.includes(file.type)) { throw new Error( "Unsupported file type. Please use PNG, JPG, GIF, SVG, WebP, or ICO.", ); } - // Validate file size if (file.size > MAX_FILE_SIZE) { throw new Error( `File too large. Maximum size is ${MAX_FILE_SIZE / 1024}KB.`, ); } - // Read file as data URL const dataUrl = await readFileAsDataUrl(file); - // SVG files don't need resizing - if (file.type === "image/svg+xml") { + if (file.type === "image/svg+xml" || file.type === "image/x-icon") { return dataUrl; } - // ICO files are typically small enough - if (file.type === "image/x-icon") { - return dataUrl; - } - - // Resize raster images return resizeImage(dataUrl, MAX_DIMENSION); } @@ -57,7 +47,7 @@ function readFileAsDataUrl(file: File): Promise { /** * Resizes an image to fit within maxDimension while maintaining aspect ratio. - * Uses canvas for resizing and returns a PNG data URL. + * Returns a PNG data URL. */ export function resizeImage( dataUrl: string, @@ -66,7 +56,6 @@ export function resizeImage( return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { - // Calculate new dimensions maintaining aspect ratio let { width, height } = img; if (width > maxDimension || height > maxDimension) { @@ -79,7 +68,6 @@ export function resizeImage( } } - // Create canvas and draw resized image const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; @@ -90,12 +78,10 @@ export function resizeImage( return; } - // Use high-quality interpolation ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = "high"; ctx.drawImage(img, 0, 0, width, height); - // Return as PNG data URL resolve(canvas.toDataURL("image/png")); }; img.onerror = () => reject(new Error("Failed to load image")); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectThumbnail/ProjectThumbnail.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectThumbnail/ProjectThumbnail.tsx index b04ee96ec45..beb098eea52 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectThumbnail/ProjectThumbnail.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectThumbnail/ProjectThumbnail.tsx @@ -8,8 +8,8 @@ interface ProjectThumbnailProps { projectName: string; projectColor: string; githubOwner: string | null; - iconUrl?: string | null; // Auto-discovered favicon - iconOverride?: string | null; // User-uploaded icon (takes priority) + iconUrl?: string | null; + iconOverride?: string | null; className?: string; } @@ -46,7 +46,6 @@ export function ProjectThumbnail({ const [imageError, setImageError] = useState(false); const [iconError, setIconError] = useState(false); - // Determine which icon to show const effectiveIcon = iconOverride || iconUrl; // Reset error state when icon source changes @@ -56,7 +55,6 @@ export function ProjectThumbnail({ prevIconRef.current = effectiveIcon; } - // Only fetch GitHub avatar if no icon is set const { data: avatarData } = electronTrpc.projects.getGitHubAvatar.useQuery( { id: projectId }, { @@ -70,7 +68,6 @@ export function ProjectThumbnail({ const firstLetter = projectName.charAt(0).toUpperCase(); const hasCustomColor = isCustomColor(projectColor); - // Border: gray by default, custom color with slight transparency when set const borderClasses = cn( "border-[1.5px]", hasCustomColor ? undefined : "border-border", @@ -79,7 +76,6 @@ export function ProjectThumbnail({ ? { borderColor: hexToRgba(projectColor, 0.6) } : undefined; - // Priority 1: Show project icon (override or discovered) if (effectiveIcon && !iconError) { return (
(), branchPrefixCustom: text("branch_prefix_custom"), - iconUrl: text("icon_url"), // Auto-discovered favicon (base64 data URL) - iconOverride: text("icon_override"), // User-uploaded icon (base64 data URL) + iconUrl: text("icon_url"), + iconOverride: text("icon_override"), }, (table) => [ index("projects_main_repo_path_idx").on(table.mainRepoPath), From e80fd6f086439450f4a0dbdb1934c09504f62033 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 3 Feb 2026 10:51:16 -0800 Subject: [PATCH 11/11] refactor(IconUploader): use native file picker instead of custom drag-and-drop Replace the custom drag-and-drop UI and client-side canvas processing with the established selectImageFile pattern (native OS file picker via Electron dialog). This aligns project icon upload with the existing avatar/logo upload flow used elsewhere in the app. --- apps/desktop/src/lib/trpc/routers/window.ts | 2 +- .../components/IconUploader/IconUploader.tsx | 267 +++++------------- .../components/IconUploader/utils.ts | 90 ------ 3 files changed, 73 insertions(+), 286 deletions(-) delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/IconUploader/utils.ts diff --git a/apps/desktop/src/lib/trpc/routers/window.ts b/apps/desktop/src/lib/trpc/routers/window.ts index e050e1847cd..aa84a27d347 100644 --- a/apps/desktop/src/lib/trpc/routers/window.ts +++ b/apps/desktop/src/lib/trpc/routers/window.ts @@ -59,7 +59,7 @@ export const createWindowRouter = (getWindow: () => BrowserWindow | null) => { const result = await dialog.showOpenDialog(window, { properties: ["openFile"], - title: "Select Organization Logo", + title: "Select Image", filters: [ { name: "Images", diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/IconUploader/IconUploader.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/IconUploader/IconUploader.tsx index 1adf574d262..312510c7ce7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/IconUploader/IconUploader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/IconUploader/IconUploader.tsx @@ -1,9 +1,8 @@ import { Button } from "@superset/ui/button"; import { cn } from "@superset/ui/utils"; -import { useCallback, useRef, useState } from "react"; -import { HiOutlinePhoto, HiXMark } from "react-icons/hi2"; +import { HiXMark } from "react-icons/hi2"; import { LuUpload } from "react-icons/lu"; -import { processIconFile } from "./utils"; +import { electronTrpc } from "renderer/lib/electron-trpc"; interface IconUploaderProps { projectName: string; @@ -26,218 +25,96 @@ export function IconUploader({ onIconChange, isPending = false, }: IconUploaderProps) { - const fileInputRef = useRef(null); - const [isDragOver, setIsDragOver] = useState(false); - const [error, setError] = useState(null); - const [isProcessing, setIsProcessing] = useState(false); + const selectImageMutation = electronTrpc.window.selectImageFile.useMutation(); const effectiveIcon = iconOverride || iconUrl; const hasCustomIcon = !!iconOverride; const hasDiscoveredIcon = !!iconUrl && !iconOverride; - - const handleFileSelect = useCallback( - async (file: File) => { - setError(null); - setIsProcessing(true); - - try { - const processedIcon = await processIconFile(file); - onIconChange(processedIcon); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to process file"); - } finally { - setIsProcessing(false); - } - }, - [onIconChange], - ); - - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(true); - }, []); - - const handleDragLeave = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(false); - }, []); - - const handleDrop = useCallback( - async (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(false); - - const files = Array.from(e.dataTransfer.files); - const imageFile = files.find((f) => f.type.startsWith("image/")); - - if (imageFile) { - await handleFileSelect(imageFile); - } - }, - [handleFileSelect], - ); - - const handleInputChange = useCallback( - async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) { - await handleFileSelect(file); - } - // Reset input so same file can be selected again - e.target.value = ""; - }, - [handleFileSelect], - ); - - const handleRemoveIcon = useCallback(() => { - setError(null); - onIconChange(null); - }, [onIconChange]); - - const handleClickUpload = () => { - fileInputRef.current?.click(); - }; + const isLoading = isPending || selectImageMutation.isPending; + + async function handleUpload() { + try { + const result = await selectImageMutation.mutateAsync(); + if (result.canceled || !result.dataUrl) return; + onIconChange(result.dataUrl); + } catch (error) { + console.error("[icon-uploader] Failed to select image:", error); + } + } const firstLetter = projectName.charAt(0).toUpperCase(); - const isLoading = isPending || isProcessing; return ( -
-
-
+
+ {effectiveIcon ? ( + {`${projectName} + ) : githubOwner ? ( + {`${projectName} + ) : ( + + {firstLetter} + + )} +
+ +
+
+ {hasCustomIcon && ( + Custom icon uploaded )} - > - {effectiveIcon ? ( - {`${projectName} - ) : githubOwner ? ( - {`${projectName} - ) : ( - - {firstLetter} + {hasDiscoveredIcon && ( + + Auto-discovered from project )} + {!effectiveIcon && githubOwner && ( + Using GitHub avatar + )} + {!effectiveIcon && !githubOwner && ( + Using default icon + )}
-
-
- {hasCustomIcon && ( - - Custom icon uploaded - - )} - {hasDiscoveredIcon && ( - - Auto-discovered from project - - )} - {!effectiveIcon && githubOwner && ( - Using GitHub avatar - )} - {!effectiveIcon && !githubOwner && ( - Using default icon - )} -
- -
+
+ + + {hasCustomIcon && ( - - {hasCustomIcon && ( - - )} -
-
-
- - {/* Drop Zone */} - {/* biome-ignore lint/a11y/useSemanticElements: Drop zone wrapper for drag-and-drop functionality */} -
{ - if (e.key === "Enter" || e.key === " ") { - handleClickUpload(); - } - }} - className={cn( - "relative border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors", - isDragOver - ? "border-primary bg-primary/5" - : "border-border hover:border-muted-foreground/50", - isLoading && "opacity-50 pointer-events-none", - )} - > -
- -
- Click to upload{" "} - or drag and drop -
-
- PNG, JPG, GIF, SVG, WebP, or ICO (max 500KB) -
+ )}
- - {isDragOver && ( -
-
- - Drop to upload -
-
- )} - - {isLoading && ( -
-
Processing...
-
- )}
- - - - {error &&
{error}
}
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/IconUploader/utils.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/IconUploader/utils.ts deleted file mode 100644 index 94500a272af..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/IconUploader/utils.ts +++ /dev/null @@ -1,90 +0,0 @@ -const ALLOWED_TYPES = [ - "image/png", - "image/jpeg", - "image/gif", - "image/svg+xml", - "image/webp", - "image/x-icon", -]; - -const MAX_FILE_SIZE = 500 * 1024; // 500KB -const MAX_DIMENSION = 128; - -/** - * Validates and processes an icon file, returning a base64 data URL. - * Resizes raster images to 128x128 max while maintaining aspect ratio. - */ -export async function processIconFile(file: File): Promise { - if (!ALLOWED_TYPES.includes(file.type)) { - throw new Error( - "Unsupported file type. Please use PNG, JPG, GIF, SVG, WebP, or ICO.", - ); - } - - if (file.size > MAX_FILE_SIZE) { - throw new Error( - `File too large. Maximum size is ${MAX_FILE_SIZE / 1024}KB.`, - ); - } - - const dataUrl = await readFileAsDataUrl(file); - - if (file.type === "image/svg+xml" || file.type === "image/x-icon") { - return dataUrl; - } - - return resizeImage(dataUrl, MAX_DIMENSION); -} - -function readFileAsDataUrl(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result as string); - reader.onerror = () => reject(new Error("Failed to read file")); - reader.readAsDataURL(file); - }); -} - -/** - * Resizes an image to fit within maxDimension while maintaining aspect ratio. - * Returns a PNG data URL. - */ -export function resizeImage( - dataUrl: string, - maxDimension: number, -): Promise { - return new Promise((resolve, reject) => { - const img = new Image(); - img.onload = () => { - let { width, height } = img; - - if (width > maxDimension || height > maxDimension) { - if (width > height) { - height = Math.round((height / width) * maxDimension); - width = maxDimension; - } else { - width = Math.round((width / height) * maxDimension); - height = maxDimension; - } - } - - const canvas = document.createElement("canvas"); - canvas.width = width; - canvas.height = height; - - const ctx = canvas.getContext("2d"); - if (!ctx) { - reject(new Error("Failed to get canvas context")); - return; - } - - ctx.imageSmoothingEnabled = true; - ctx.imageSmoothingQuality = "high"; - ctx.drawImage(img, 0, 0, width, height); - - resolve(canvas.toDataURL("image/png")); - }; - img.onerror = () => reject(new Error("Failed to load image")); - img.src = dataUrl; - }); -}