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:
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user