From 3d9d4179a1027cfced36de4cafab5dd935bb987c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 28 May 2026 19:38:41 -0700 Subject: [PATCH] Add custom repo hex color picker (#3022) --- package.json | 1 + pnpm-lock.yaml | 14 ++ src/main/ipc/repos.ts | 9 ++ src/main/persistence.test.ts | 20 +++ src/main/persistence.ts | 15 +- .../rpc/methods/repo-badge-color.test.ts | 38 +++++ src/main/runtime/rpc/methods/repo.ts | 10 +- .../settings/RepositoryIconPicker.tsx | 56 +++++--- .../components/settings/RepositoryPane.tsx | 35 +++-- .../components/settings/repository-search.ts | 1 + .../sidebar/project-header-color.test.ts | 8 +- .../sidebar/project-header-color.ts | 11 +- .../src/components/ui/color-picker.test.tsx | 14 ++ .../src/components/ui/color-picker.tsx | 131 ++++++++++++++++++ src/renderer/src/store/slices/repos.ts | 9 ++ src/shared/repo-badge-color.test.ts | 24 ++++ src/shared/repo-badge-color.ts | 29 ++++ 17 files changed, 384 insertions(+), 41 deletions(-) create mode 100644 src/main/runtime/rpc/methods/repo-badge-color.test.ts create mode 100644 src/renderer/src/components/ui/color-picker.test.tsx create mode 100644 src/renderer/src/components/ui/color-picker.tsx create mode 100644 src/shared/repo-badge-color.test.ts create mode 100644 src/shared/repo-badge-color.ts diff --git a/package.json b/package.json index 47b828c7131..6993c59f913 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,7 @@ "posthog-node": "^5.33.3", "qrcode": "^1.5.4", "radix-ui": "^1.4.3", + "react-colorful": "^5.7.0", "react-markdown": "^10.1.0", "rehype-highlight": "^7.0.2", "rehype-katex": "^7.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3312b0c663f..9be400fdf3e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -178,6 +178,9 @@ importers: radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react-colorful: + specifier: ^5.7.0 + version: 5.7.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.14)(react@19.2.5) @@ -5469,6 +5472,12 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true + react-colorful@5.7.0: + resolution: {integrity: sha512-fuesYIemttah97XmsIHmz4OORDHiSFzyc9HMAIrCHJou2jaRQmL8cFJ76K4zQhhj8jzwOBlOi4BaGTjjOZCfTg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + react-dom@19.2.5: resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} peerDependencies: @@ -11887,6 +11896,11 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 + react-colorful@5.7.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-dom@19.2.5(react@19.2.5): dependencies: react: 19.2.5 diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index f51de2d2535..66ec75eaf9f 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -17,6 +17,7 @@ import type { } from '../../shared/types' import { isFolderRepo } from '../../shared/repo-kind' import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants' +import { normalizeRepoBadgeColor } from '../../shared/repo-badge-color' import { sanitizeRepoIcon } from '../../shared/repo-icon' import { normalizeRepoSourceControlAiOverrides } from '../../shared/source-control-ai' import { invalidateAuthorizedRootsCache } from './filesystem-auth' @@ -955,6 +956,14 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v updates.repoIcon = repoIcon } } + if ('badgeColor' in updates) { + const badgeColor = normalizeRepoBadgeColor(updates.badgeColor) + if (!badgeColor) { + delete updates.badgeColor + } else { + updates.badgeColor = badgeColor + } + } if ( 'externalWorktreeVisibility' in updates && updates.externalWorktreeVisibility !== undefined && diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 9dde81a4838..6bde67ff4aa 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -1606,6 +1606,26 @@ describe('Store', () => { expect(store.getRepo('r1')!.repoIcon).toBeUndefined() }) + it('updateRepo normalizes custom repo badge colors before storing', async () => { + const store = await createStore() + store.addRepo(makeRepo()) + + const updated = store.updateRepo('r1', { badgeColor: ' ABCDEF ' }) + + expect(updated!.badgeColor).toBe('#abcdef') + expect(store.getRepo('r1')!.badgeColor).toBe('#abcdef') + }) + + it('updateRepo ignores invalid repo badge colors without clearing the existing color', async () => { + const store = await createStore() + store.addRepo(makeRepo({ badgeColor: '#123456' })) + + const updated = store.updateRepo('r1', { badgeColor: 'blue' }) + + expect(updated!.badgeColor).toBe('#123456') + expect(store.getRepo('r1')!.badgeColor).toBe('#123456') + }) + it('getRepo does not expose invalid persisted repo icons', async () => { const store = await createStore() store.addRepo( diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 2a1cf94ec47..864129326ac 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -100,6 +100,7 @@ import { } from '../shared/workspace-statuses' import { isLegacyRepoForExternalWorktreeVisibility } from '../shared/worktree-ownership' import { sanitizeRepoIcon } from '../shared/repo-icon' +import { normalizeRepoBadgeColor } from '../shared/repo-badge-color' import { clearMissingProjectGroupMemberships, createProjectGroup, @@ -476,10 +477,18 @@ function readLegacySidekickFlag(parsed: PersistedState | undefined): boolean | u return (parsed?.settings as { experimentalSidekick?: boolean } | undefined)?.experimentalSidekick } -function sanitizeRepoUpdatesForPersistence>>( - updates: T -): T { +function sanitizeRepoUpdatesForPersistence< + T extends Partial> +>(updates: T): T { const sanitized = { ...updates } + if ('badgeColor' in sanitized) { + const badgeColor = normalizeRepoBadgeColor(sanitized.badgeColor) + if (!badgeColor) { + delete sanitized.badgeColor + } else { + sanitized.badgeColor = badgeColor + } + } if ('repoIcon' in sanitized) { const repoIcon = sanitizeRepoIcon(sanitized.repoIcon) if (repoIcon === undefined) { diff --git a/src/main/runtime/rpc/methods/repo-badge-color.test.ts b/src/main/runtime/rpc/methods/repo-badge-color.test.ts new file mode 100644 index 00000000000..32b50ab0552 --- /dev/null +++ b/src/main/runtime/rpc/methods/repo-badge-color.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { REPO_METHODS } from './repo' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('repo badge color RPC updates', () => { + it('normalizes repo badge colors before runtime updates', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateRepo: vi.fn().mockResolvedValue({ + id: 'repo-1', + path: '/srv/repo', + badgeColor: '#abcdef' + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('repo.update', { + repo: 'repo-1', + updates: { badgeColor: ' ABCDEF ' } + }) + ) + + expect(runtime.updateRepo).toHaveBeenCalledWith('repo-1', { + badgeColor: '#abcdef' + }) + expect(response).toMatchObject({ + ok: true, + result: { repo: { id: 'repo-1', badgeColor: '#abcdef' } } + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts index 22abcf495cb..611ed827ac5 100644 --- a/src/main/runtime/rpc/methods/repo.ts +++ b/src/main/runtime/rpc/methods/repo.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' import { sanitizeRepoIcon } from '../../../../shared/repo-icon' +import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color' import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source-control-ai' const RepoSelector = z.object({ @@ -36,10 +37,17 @@ const RepoSourceControlAiOverrides = z value === undefined ? undefined : normalizeRepoSourceControlAiOverrides(value) ) +const RepoBadgeColor = z + .unknown() + .optional() + .transform((value) => + value === undefined ? undefined : (normalizeRepoBadgeColor(value) ?? undefined) + ) + const RepoUpdate = RepoSelector.extend({ updates: z.object({ displayName: OptionalString, - badgeColor: OptionalString, + badgeColor: RepoBadgeColor, repoIcon: z .unknown() .transform((value) => sanitizeRepoIcon(value)) diff --git a/src/renderer/src/components/settings/RepositoryIconPicker.tsx b/src/renderer/src/components/settings/RepositoryIconPicker.tsx index 697ee909531..87564e927d8 100644 --- a/src/renderer/src/components/settings/RepositoryIconPicker.tsx +++ b/src/renderer/src/components/settings/RepositoryIconPicker.tsx @@ -3,12 +3,14 @@ import { toast } from 'sonner' import { Github, Image, Link2, RotateCcw } from 'lucide-react' import type { Repo } from '../../../../shared/types' import { faviconUrlFromWebsite, type RepoIcon } from '../../../../shared/repo-icon' -import { REPO_COLORS } from '../../../../shared/constants' +import { DEFAULT_REPO_BADGE_COLOR, REPO_COLORS } from '../../../../shared/constants' +import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color' import { Button } from '../ui/button' import { Input } from '../ui/input' import { Label } from '../ui/label' import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' +import { ColorPicker } from '../ui/color-picker' import { RepoIconGlyph, REPO_LUCIDE_ICON_OPTIONS } from '../repo/repo-icon' import { cn } from '@/lib/utils' import { useAppStore } from '@/store' @@ -31,6 +33,8 @@ export function RepositoryIconPicker({ const selectedLucideName = repo.repoIcon?.type === 'lucide' ? repo.repoIcon.name : repo.repoIcon == null ? 'Folder' : null const selectedEmoji = repo.repoIcon?.type === 'emoji' ? repo.repoIcon.emoji : '' + const selectedBadgeColor = normalizeRepoBadgeColor(repo.badgeColor) ?? DEFAULT_REPO_BADGE_COLOR + const isPresetBadgeColor = REPO_COLORS.some((color) => color === selectedBadgeColor) const initialTab = repo.repoIcon?.type === 'image' ? 'image' : repo.repoIcon?.type === 'emoji' ? 'emoji' : 'icon' const runtimeTarget = useMemo( @@ -52,6 +56,7 @@ export function RepositoryIconPicker({ }, [repo.repoIcon, selectedLucideName]) const setIcon = (repoIcon: RepoIcon | null) => updateRepo(repo.id, { repoIcon }) + const setBadgeColor = (badgeColor: string) => updateRepo(repo.id, { badgeColor }) const handleUploadImage = async () => { try { @@ -115,7 +120,7 @@ export function RepositoryIconPicker({
@@ -135,22 +140,39 @@ export function RepositoryIconPicker({
-
- {REPO_COLORS.map((color) => ( -
diff --git a/src/renderer/src/components/settings/RepositoryPane.tsx b/src/renderer/src/components/settings/RepositoryPane.tsx index bab970885cc..8bdb528f5d5 100644 --- a/src/renderer/src/components/settings/RepositoryPane.tsx +++ b/src/renderer/src/components/settings/RepositoryPane.tsx @@ -6,6 +6,7 @@ import { Input } from '../ui/input' import { Label } from '../ui/label' import { Separator } from '../ui/separator' import { Trash2 } from 'lucide-react' +import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' import { BaseRefPicker } from './BaseRefPicker' import { RepositoryHooksSection } from './RepositoryHooksSection' import { McpConfigSection } from './McpConfigSection' @@ -107,6 +108,8 @@ export function RepositoryPane({ const mcpEntries = allEntries.filter((entry) => entry.title === 'MCP Configs') const symlinkEntries = allEntries.filter((entry) => entry.title === 'Worktree Symlinks') const sourceControlAiEntries = allEntries.filter((entry) => entry.title === 'Source Control AI') + const removeProjectLabel = + confirmingRemove === repo.id ? 'Confirm Remove Project' : 'Remove Project' const hooksSection = !isFolder && (forceFullPaneForRepoMatch || matchesSettingsSearch(searchQuery, hooksEntries)) ? ( @@ -129,9 +132,9 @@ export function RepositoryPane({ // most-edited surface and should beat MCP/symlinks/sparse-presets. const visibleSections = [ forceFullPaneForRepoMatch || matchesSettingsSearch(searchQuery, identityEntries) ? ( -
+
-
+

Identity

Project-specific display details for the sidebar and tabs. @@ -149,18 +152,26 @@ export function RepositoryPane({ title="Remove Project" description="Remove this project from Orca." keywords={[repo.displayName, 'delete', 'project', 'repository']} + className="absolute top-0 right-0 z-10 w-auto max-w-none" forceVisible={forceFullPaneForRepoMatch} > - + + + + + + {removeProjectLabel} + +

diff --git a/src/renderer/src/components/settings/repository-search.ts b/src/renderer/src/components/settings/repository-search.ts index 38bc195c1bc..5696e7d91f9 100644 --- a/src/renderer/src/components/settings/repository-search.ts +++ b/src/renderer/src/components/settings/repository-search.ts @@ -18,6 +18,7 @@ export function getRepositoryPaneSearchEntries(repo: Repo): SettingsSearchEntry[ 'project icon', 'repository icon', 'color', + 'hex', 'badge', 'emoji', 'favicon' diff --git a/src/renderer/src/components/sidebar/project-header-color.test.ts b/src/renderer/src/components/sidebar/project-header-color.test.ts index 6f80755deb1..e02d622e3b3 100644 --- a/src/renderer/src/components/sidebar/project-header-color.test.ts +++ b/src/renderer/src/components/sidebar/project-header-color.test.ts @@ -19,8 +19,12 @@ describe('resolveRepoHeaderColor', () => { expect(resolveRepoHeaderColor(` ${REPO_COLORS[4].toUpperCase()} `)).toBe(REPO_COLORS[4]) }) - it('falls back for out-of-palette colors', () => { - expect(resolveRepoHeaderColor('#123456')).toBe(DEFAULT_REPO_BADGE_COLOR) + it('returns normalized custom hex colors', () => { + expect(resolveRepoHeaderColor(' #123ABC ')).toBe('#123abc') + }) + + it('falls back for invalid colors', () => { + expect(resolveRepoHeaderColor('blue')).toBe(DEFAULT_REPO_BADGE_COLOR) }) }) diff --git a/src/renderer/src/components/sidebar/project-header-color.ts b/src/renderer/src/components/sidebar/project-header-color.ts index 486a27de20a..8a83920bd11 100644 --- a/src/renderer/src/components/sidebar/project-header-color.ts +++ b/src/renderer/src/components/sidebar/project-header-color.ts @@ -1,18 +1,17 @@ import { DEFAULT_REPO_BADGE_COLOR, REPO_COLORS } from '../../../../shared/constants' +import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color' const PROJECT_GROUP_HEADER_KEY_PREFIX = 'repo:' export function resolveRepoHeaderColor(badgeColor: string | null | undefined): string { - const normalizedBadgeColor = badgeColor?.trim().toLowerCase() + const normalizedBadgeColor = normalizeRepoBadgeColor(badgeColor) if (!normalizedBadgeColor) { return DEFAULT_REPO_BADGE_COLOR } - // Why: persisted repo colors are rendered as inline CSS here, so only the - // documented palette should reach the sidebar. - return ( - REPO_COLORS.find((repoColor) => repoColor === normalizedBadgeColor) ?? DEFAULT_REPO_BADGE_COLOR - ) + // Why: persisted repo colors are rendered as inline CSS here, so only + // normalized hex values from the palette or custom picker reach the sidebar. + return REPO_COLORS.find((repoColor) => repoColor === normalizedBadgeColor) ?? normalizedBadgeColor } export function resolveProjectGroupHeaderColor(args: { diff --git a/src/renderer/src/components/ui/color-picker.test.tsx b/src/renderer/src/components/ui/color-picker.test.tsx new file mode 100644 index 00000000000..33bc879d43c --- /dev/null +++ b/src/renderer/src/components/ui/color-picker.test.tsx @@ -0,0 +1,14 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { ColorPicker } from './color-picker' + +describe('ColorPicker', () => { + it('renders a normalized custom color trigger', () => { + const html = renderToStaticMarkup( + + ) + + expect(html).toContain('aria-label="Custom repo color"') + expect(html).toContain('#abcdef') + }) +}) diff --git a/src/renderer/src/components/ui/color-picker.tsx b/src/renderer/src/components/ui/color-picker.tsx new file mode 100644 index 00000000000..a5c59582726 --- /dev/null +++ b/src/renderer/src/components/ui/color-picker.tsx @@ -0,0 +1,131 @@ +import * as React from 'react' +import { HexColorPicker } from 'react-colorful' + +import { normalizeRepoBadgeColor, resolveRepoBadgeColor } from '../../../../shared/repo-badge-color' +import { cn } from '@/lib/utils' +import { Button } from './button' +import { Input } from './input' +import { Label } from './label' +import { Popover, PopoverContent, PopoverTrigger } from './popover' + +type ColorPickerProps = { + value: string + onChange: (value: string) => void + label: string + className?: string + defaultOpen?: boolean + selected?: boolean + triggerLabel?: string + showHexInTrigger?: boolean +} + +const FULL_HEX_COLOR_PATTERN = /^#?[0-9a-fA-F]{6}$/ + +export function ColorPicker({ + value, + onChange, + label, + className, + defaultOpen, + selected, + triggerLabel, + showHexInTrigger +}: ColorPickerProps): React.JSX.Element { + const inputId = React.useId() + const currentColor = resolveRepoBadgeColor(value) + const [draftState, setDraftState] = React.useState(() => ({ + syncedColor: currentColor, + draft: currentColor, + isEditing: false + })) + const draft = + draftState.isEditing || draftState.syncedColor === currentColor + ? draftState.draft + : currentColor + const draftColor = normalizeRepoBadgeColor(draft) + const swatchColor = draftColor ?? currentColor + const hasInvalidDraft = draft.trim().length > 0 && !draftColor + const shouldShowTriggerHex = showHexInTrigger ?? !triggerLabel + + const updateDraft = (nextDraft: string): void => { + const nextColor = normalizeRepoBadgeColor(nextDraft) + setDraftState({ syncedColor: currentColor, draft: nextDraft, isEditing: true }) + if (nextColor && FULL_HEX_COLOR_PATTERN.test(nextDraft.trim())) { + onChange(nextColor) + } + } + + const updateColor = (nextColor: string): void => { + const normalized = resolveRepoBadgeColor(nextColor) + setDraftState({ syncedColor: currentColor, draft: normalized, isEditing: true }) + onChange(normalized) + } + + return ( + + + + + +
+ +
+ + {swatchColor} +
+ + setDraftState({ + syncedColor: currentColor, + draft, + isEditing: true + }) + } + onChange={(event) => updateDraft(event.target.value)} + onBlur={() => { + if (draftColor) { + setDraftState({ syncedColor: currentColor, draft: draftColor, isEditing: false }) + onChange(draftColor) + } else { + setDraftState({ syncedColor: currentColor, draft: currentColor, isEditing: false }) + } + }} + placeholder={currentColor} + aria-invalid={hasInvalidDraft} + className="font-mono text-xs uppercase" + /> + {hasInvalidDraft ?

Invalid hex color.

: null} +
+
+
+ ) +} diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index b9f4d09484d..f28ed57a119 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -13,6 +13,7 @@ import type { } from '../../../../shared/types' import { isGitRepoKind } from '../../../../shared/repo-kind' import { sanitizeRepoIcon } from '../../../../shared/repo-icon' +import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color' import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups' import { getRepoIdFromWorktreeId } from './worktree-helpers' import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' @@ -41,6 +42,14 @@ type RepoUpdate = Partial< function sanitizeRepoUpdate(updates: RepoUpdate): RepoUpdate { const sanitized = { ...updates } + if ('badgeColor' in sanitized) { + const badgeColor = normalizeRepoBadgeColor(sanitized.badgeColor) + if (!badgeColor) { + delete sanitized.badgeColor + } else { + sanitized.badgeColor = badgeColor + } + } if ('repoIcon' in sanitized) { const repoIcon = sanitizeRepoIcon(sanitized.repoIcon) if (repoIcon === undefined) { diff --git a/src/shared/repo-badge-color.test.ts b/src/shared/repo-badge-color.test.ts new file mode 100644 index 00000000000..c82af361cf1 --- /dev/null +++ b/src/shared/repo-badge-color.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { DEFAULT_REPO_BADGE_COLOR } from './constants' +import { normalizeRepoBadgeColor, resolveRepoBadgeColor } from './repo-badge-color' + +describe('repo badge color normalization', () => { + it('normalizes six-digit hex colors', () => { + expect(normalizeRepoBadgeColor(' ABCDEF ')).toBe('#abcdef') + expect(normalizeRepoBadgeColor('#ABCDEF')).toBe('#abcdef') + }) + + it('expands shorthand hex colors', () => { + expect(normalizeRepoBadgeColor('#abc')).toBe('#aabbcc') + }) + + it('rejects non-hex colors', () => { + expect(normalizeRepoBadgeColor('blue')).toBeNull() + expect(normalizeRepoBadgeColor('url(javascript:alert(1))')).toBeNull() + expect(normalizeRepoBadgeColor('#12zz12')).toBeNull() + }) + + it('falls back to the default repo color when resolving invalid input', () => { + expect(resolveRepoBadgeColor('blue')).toBe(DEFAULT_REPO_BADGE_COLOR) + }) +}) diff --git a/src/shared/repo-badge-color.ts b/src/shared/repo-badge-color.ts new file mode 100644 index 00000000000..9b578603d01 --- /dev/null +++ b/src/shared/repo-badge-color.ts @@ -0,0 +1,29 @@ +import { DEFAULT_REPO_BADGE_COLOR, REPO_COLORS } from './constants' + +const HEX_COLOR_PATTERN = /^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/ + +export function normalizeRepoBadgeColor(value: unknown): string | null { + if (typeof value !== 'string') { + return null + } + + const match = value.trim().match(HEX_COLOR_PATTERN) + if (!match) { + return null + } + + const rawHex = match[1].toLowerCase() + const hex = + rawHex.length === 3 + ? rawHex + .split('') + .map((part) => part + part) + .join('') + : rawHex + const normalized = `#${hex}` + return REPO_COLORS.find((repoColor) => repoColor === normalized) ?? normalized +} + +export function resolveRepoBadgeColor(value: unknown): string { + return normalizeRepoBadgeColor(value) ?? DEFAULT_REPO_BADGE_COLOR +}