From 8b1bb90ce3b7a6e530e007c8d2ba3d6c025e1c20 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 29 May 2026 14:06:47 +0700 Subject: [PATCH] Show colored icons for projects without an icon The letter-on-grey placeholder is replaced by a fill whose color is a deterministic hash of the project key, so each project keeps a stable color across the sidebar, projects list, and settings header. Mid- saturation tones keep the white letter legible in both themes. --- .../app/src/components/project-icon-view.tsx | 45 ++++++++++++++++ .../src/components/sidebar-workspace-list.tsx | 51 +++++++++++-------- .../app/src/hooks/use-project-icon-query.ts | 7 +++ .../src/screens/project-settings-screen.tsx | 40 +++++++++------ packages/app/src/screens/projects-screen.tsx | 31 ++++++----- packages/app/src/utils/project-icon-color.ts | 26 ++++++++++ 6 files changed, 147 insertions(+), 53 deletions(-) create mode 100644 packages/app/src/components/project-icon-view.tsx create mode 100644 packages/app/src/utils/project-icon-color.ts diff --git a/packages/app/src/components/project-icon-view.tsx b/packages/app/src/components/project-icon-view.tsx new file mode 100644 index 000000000..95bc886cb --- /dev/null +++ b/packages/app/src/components/project-icon-view.tsx @@ -0,0 +1,45 @@ +import { useMemo } from "react"; +import { + Image, + type ImageStyle, + type StyleProp, + Text, + type TextStyle, + View, + type ViewStyle, +} from "react-native"; +import { deriveProjectIconColor } from "@/utils/project-icon-color"; + +const WHITE_TEXT = { color: "#ffffff" } as const; + +export function ProjectIconView({ + iconDataUri, + initial, + projectKey, + imageStyle, + fallbackStyle, + textStyle, +}: { + iconDataUri: string | null; + initial: string; + projectKey: string; + imageStyle: StyleProp; + fallbackStyle: StyleProp; + textStyle: StyleProp; +}) { + const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]); + const fallbackStyles = useMemo( + () => [fallbackStyle, { backgroundColor: deriveProjectIconColor(projectKey) }], + [fallbackStyle, projectKey], + ); + const textStyles = useMemo(() => [textStyle, WHITE_TEXT], [textStyle]); + + if (iconDataUri) { + return ; + } + return ( + + {initial} + + ); +} diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index bc8b2bbaa..4c9e2299a 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -2,7 +2,6 @@ import { View, Text, Pressable, - Image, Platform, ActivityIndicator, StatusBar, @@ -14,6 +13,7 @@ import { import * as Haptics from "expo-haptics"; import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query"; import { slugify, validateBranchSlug, MAX_SLUG_LENGTH } from "@getpaseo/protocol/branch-slug"; +import { ProjectIconView } from "@/components/project-icon-view"; import { AdaptiveRenameModal } from "@/components/rename-modal"; import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys"; import { @@ -57,7 +57,7 @@ import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list"; import type { DraggableListDragHandleProps } from "./draggable-list.types"; import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { projectIconQueryKey } from "@/hooks/use-project-icon-query"; +import { projectIconQueryKey, projectIconToDataUri } from "@/hooks/use-project-icon-query"; import { buildHostNewWorkspaceRoute, buildProjectSettingsRoute, @@ -119,13 +119,6 @@ import { WorkspaceHoverCard } from "@/components/workspace-hover-card"; import { GitHubIcon } from "@/components/icons/github-icon"; import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform"; -function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null { - if (!icon) { - return null; - } - return `data:${icon.mimeType};base64,${icon.data}`; -} - const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey; const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey; @@ -471,6 +464,7 @@ function ProjectLeadingVisual({ displayName, iconDataUri, workspace, + projectKey, chevron = null, showChevron = false, isArchiving = false, @@ -478,6 +472,7 @@ function ProjectLeadingVisual({ displayName: string; iconDataUri: string | null; workspace: SidebarWorkspaceEntry | null; + projectKey: string; chevron?: "expand" | "collapse" | null; showChevron?: boolean; isArchiving?: boolean; @@ -502,7 +497,11 @@ function ProjectLeadingVisual({ if (!shouldShowWorkspaceStatus || !activeWorkspace) { return ( - + ); } @@ -511,6 +510,7 @@ function ProjectLeadingVisual({ ({ uri: iconDataUri ?? "" }), [iconDataUri]); - if (iconDataUri) { - return ; - } return ( - - {placeholderInitial} - + ); } function ProjectLeadingVisualStatus({ iconDataUri, placeholderInitial, + projectKey, isArchiving, shouldShowSyncedLoader, activeWorkspace, }: { iconDataUri: string | null; placeholderInitial: string; + projectKey: string; isArchiving: boolean; shouldShowSyncedLoader: boolean; activeWorkspace: SidebarWorkspaceEntry; @@ -852,7 +857,11 @@ function ProjectLeadingVisualStatus({ return ( - + {dotColorStyle ? ( ({ width: "100%", height: "100%", borderRadius: theme.borderRadius.sm, - borderWidth: 1, - borderColor: theme.colors.border, alignItems: "center", justifyContent: "center", }, projectIconFallbackText: { - color: theme.colors.foregroundMuted, fontSize: 9, }, projectTitle: { diff --git a/packages/app/src/hooks/use-project-icon-query.ts b/packages/app/src/hooks/use-project-icon-query.ts index f0353da59..79cd9f64a 100644 --- a/packages/app/src/hooks/use-project-icon-query.ts +++ b/packages/app/src/hooks/use-project-icon-query.ts @@ -6,6 +6,13 @@ export function projectIconQueryKey(serverId: string, cwd: string) { return ["projectIcon", serverId, cwd] as const; } +export function projectIconToDataUri(icon: ProjectIcon | null): string | null { + if (!icon) { + return null; + } + return `data:${icon.mimeType};base64,${icon.data}`; +} + interface UseProjectIconQueryOptions { serverId: string; cwd: string; diff --git a/packages/app/src/screens/project-settings-screen.tsx b/packages/app/src/screens/project-settings-screen.tsx index 9d1411676..977eeed63 100644 --- a/packages/app/src/screens/project-settings-screen.tsx +++ b/packages/app/src/screens/project-settings-screen.tsx @@ -1,10 +1,11 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { Image, Pressable, Text, TextInput, View } from "react-native"; +import { Pressable, Text, TextInput, View } from "react-native"; import { router } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, Check, ChevronDown, MoreVertical, Pencil, Plus, X } from "lucide-react-native"; -import { useProjectIconQuery } from "@/hooks/use-project-icon-query"; +import { ProjectIconView } from "@/components/project-icon-view"; +import { projectIconToDataUri, useProjectIconQuery } from "@/hooks/use-project-icon-query"; import type { PaseoConfigRaw, PaseoConfigRevision, @@ -234,7 +235,11 @@ function ProjectSettingsBody({ - + @@ -880,19 +885,26 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) { ); } -function ProjectTitleIcon({ host, projectName }: { host: ProjectHostEntry; projectName: string }) { +function ProjectTitleIcon({ + host, + projectName, + projectKey, +}: { + host: ProjectHostEntry; + projectName: string; + projectKey: string; +}) { const initial = projectName.trim().charAt(0).toUpperCase() || "?"; const { icon } = useProjectIconQuery({ serverId: host.serverId, cwd: host.repoRoot }); - const iconDataUri = - icon && icon.data && icon.mimeType ? `data:${icon.mimeType};base64,${icon.data}` : null; - const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]); - if (iconDataUri) { - return ; - } return ( - - {initial} - + ); } @@ -1288,12 +1300,10 @@ const styles = StyleSheet.create((theme) => ({ width: 28, height: 28, borderRadius: theme.borderRadius.md, - backgroundColor: theme.colors.surface2, alignItems: "center", justifyContent: "center", }, titleIconFallbackText: { - color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, }, diff --git a/packages/app/src/screens/projects-screen.tsx b/packages/app/src/screens/projects-screen.tsx index 0e9816d8f..2223740a8 100644 --- a/packages/app/src/screens/projects-screen.tsx +++ b/packages/app/src/screens/projects-screen.tsx @@ -1,10 +1,11 @@ -import { useCallback, useMemo } from "react"; -import { Image, Pressable, Text, View, type PressableStateCallbackType } from "react-native"; +import { useCallback } from "react"; +import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { router } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ChevronRight } from "lucide-react-native"; +import { ProjectIconView } from "@/components/project-icon-view"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; -import { useProjectIconQuery } from "@/hooks/use-project-icon-query"; +import { projectIconToDataUri, useProjectIconQuery } from "@/hooks/use-project-icon-query"; import { useProjects, type ProjectHostError } from "@/hooks/use-projects"; import { settingsStyles } from "@/styles/settings"; import { buildProjectSettingsRoute } from "@/utils/host-routes"; @@ -101,7 +102,7 @@ function ProjectRow({ project, isFirst, isSelected }: ProjectRowProps) { > - + {projectName} @@ -115,26 +116,26 @@ function ProjectRow({ project, isFirst, isSelected }: ProjectRowProps) { function ProjectRowIcon({ host, projectName, + projectKey, }: { host: ProjectHostEntry | undefined; projectName: string; + projectKey: string; }) { const initial = projectName.trim().charAt(0).toUpperCase() || "?"; const { icon } = useProjectIconQuery({ serverId: host?.serverId ?? "", cwd: host?.repoRoot ?? "", }); - const iconDataUri = - icon && icon.data && icon.mimeType ? `data:${icon.mimeType};base64,${icon.data}` : null; - const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]); - - if (iconDataUri) { - return ; - } return ( - - {initial} - + ); } @@ -195,12 +196,10 @@ const styles = StyleSheet.create((theme) => ({ width: 16, height: 16, borderRadius: theme.borderRadius.sm, - backgroundColor: theme.colors.surface2, alignItems: "center", justifyContent: "center", }, iconFallbackText: { - color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, }, spinnerColor: { diff --git a/packages/app/src/utils/project-icon-color.ts b/packages/app/src/utils/project-icon-color.ts new file mode 100644 index 000000000..ed9dda460 --- /dev/null +++ b/packages/app/src/utils/project-icon-color.ts @@ -0,0 +1,26 @@ +// Medium-saturation tones chosen so a white letter stays legible on top in both +// light and dark themes. Soft and colorful, but not pale pastels. +const PROJECT_ICON_COLORS = [ + "#8b5cf6", // violet + "#0ea5e9", // sky + "#10b981", // emerald + "#f97316", // orange + "#ec4899", // pink + "#6366f1", // indigo + "#14b8a6", // teal + "#ef4444", // red + "#eab308", // amber + "#3b82f6", // blue +]; + +function hashProjectKey(projectKey: string): number { + let hash = 0; + for (const character of projectKey) { + hash = (hash * 31 + character.charCodeAt(0)) >>> 0; + } + return hash; +} + +export function deriveProjectIconColor(projectKey: string): string { + return PROJECT_ICON_COLORS[hashProjectKey(projectKey) % PROJECT_ICON_COLORS.length]; +}