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.
This commit is contained in:
Mohamed Boudra
2026-05-29 14:06:47 +07:00
parent 524aef5d75
commit 8b1bb90ce3
6 changed files with 147 additions and 53 deletions
@@ -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<ImageStyle>;
fallbackStyle: StyleProp<ViewStyle>;
textStyle: StyleProp<TextStyle>;
}) {
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 <Image source={imageSource} style={imageStyle} />;
}
return (
<View style={fallbackStyles}>
<Text style={textStyles}>{initial}</Text>
</View>
);
}
@@ -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 (
<View style={styles.projectLeadingVisualSlot}>
<ProjectIcon iconDataUri={iconDataUri} placeholderInitial={placeholderInitial} />
<ProjectIcon
iconDataUri={iconDataUri}
placeholderInitial={placeholderInitial}
projectKey={projectKey}
/>
</View>
);
}
@@ -511,6 +510,7 @@ function ProjectLeadingVisual({
<ProjectLeadingVisualStatus
iconDataUri={iconDataUri}
placeholderInitial={placeholderInitial}
projectKey={projectKey}
isArchiving={isArchiving}
shouldShowSyncedLoader={shouldShowSyncedLoader}
activeWorkspace={activeWorkspace}
@@ -789,30 +789,35 @@ function WorkspaceKebabMenu({
function ProjectIcon({
iconDataUri,
placeholderInitial,
projectKey,
}: {
iconDataUri: string | null;
placeholderInitial: string;
projectKey: string;
}) {
const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]);
if (iconDataUri) {
return <Image source={imageSource} style={styles.projectIcon} />;
}
return (
<View style={styles.projectIconFallback}>
<Text style={styles.projectIconFallbackText}>{placeholderInitial}</Text>
</View>
<ProjectIconView
iconDataUri={iconDataUri}
initial={placeholderInitial}
projectKey={projectKey}
imageStyle={styles.projectIcon}
fallbackStyle={styles.projectIconFallback}
textStyle={styles.projectIconFallbackText}
/>
);
}
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 (
<View style={styles.projectLeadingVisualSlot}>
<ProjectIcon iconDataUri={iconDataUri} placeholderInitial={placeholderInitial} />
<ProjectIcon
iconDataUri={iconDataUri}
placeholderInitial={placeholderInitial}
projectKey={projectKey}
/>
{dotColorStyle ? (
<StatusDotOverlay
dotColorStyle={dotColorStyle}
@@ -1244,6 +1253,7 @@ function ProjectHeaderRow({
displayName={displayName}
iconDataUri={iconDataUri}
workspace={workspace}
projectKey={project.projectKey}
chevron={chevron}
showChevron={isHovered && chevron !== null}
isArchiving={isArchiving}
@@ -2350,7 +2360,7 @@ export function SidebarWorkspaceList({
const result = await client.requestProjectIcon(request.cwd);
return result.icon;
},
select: toProjectIconDataUri,
select: projectIconToDataUri,
enabled: Boolean(
getHostRuntimeStore().getClient(request.serverId) &&
isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)) &&
@@ -2713,13 +2723,10 @@ const styles = StyleSheet.create((theme) => ({
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: {
@@ -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;
@@ -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({
<View style={styles.headerBlock}>
<View style={styles.titleRow}>
<ProjectTitleIcon host={selectedHost} projectName={project.projectName} />
<ProjectTitleIcon
host={selectedHost}
projectName={project.projectName}
projectKey={project.projectKey}
/>
<ProjectNameEditor project={project} client={client} />
</View>
<HostContext hosts={hosts} selectedHost={selectedHost} onSelectHost={onSelectHost} />
@@ -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 <Image source={imageSource} style={styles.titleIcon} />;
}
return (
<View style={styles.titleIconFallback}>
<Text style={styles.titleIconFallbackText}>{initial}</Text>
</View>
<ProjectIconView
iconDataUri={projectIconToDataUri(icon)}
initial={initial}
projectKey={projectKey}
imageStyle={styles.titleIcon}
fallbackStyle={styles.titleIconFallback}
textStyle={styles.titleIconFallbackText}
/>
);
}
@@ -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,
},
+15 -16
View File
@@ -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) {
>
<View style={styles.rowMain}>
<View style={styles.leading}>
<ProjectRowIcon host={leadingHost} projectName={projectName} />
<ProjectRowIcon host={leadingHost} projectName={projectName} projectKey={projectKey} />
</View>
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{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 <Image source={imageSource} style={styles.iconImage} />;
}
return (
<View style={styles.iconFallback}>
<Text style={styles.iconFallbackText}>{initial}</Text>
</View>
<ProjectIconView
iconDataUri={projectIconToDataUri(icon)}
initial={initial}
projectKey={projectKey}
imageStyle={styles.iconImage}
fallbackStyle={styles.iconFallback}
textStyle={styles.iconFallbackText}
/>
);
}
@@ -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: {
@@ -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];
}