mirror of
https://github.com/stablyai/orca.git
synced 2026-10-05 16:02:22 +00:00
Add custom repo hex color picker (#3022)
This commit is contained in:
@@ -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",
|
||||
|
||||
Generated
+14
@@ -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
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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(
|
||||
|
||||
+12
-3
@@ -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<T extends Partial<Pick<Repo, 'repoIcon'>>>(
|
||||
updates: T
|
||||
): T {
|
||||
function sanitizeRepoUpdatesForPersistence<
|
||||
T extends Partial<Pick<Repo, 'badgeColor' | 'repoIcon'>>
|
||||
>(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) {
|
||||
|
||||
@@ -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' } }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
|
||||
@@ -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({
|
||||
<div className="flex items-center gap-3">
|
||||
<RepoIconGlyph
|
||||
repoIcon={repo.repoIcon}
|
||||
color={repo.badgeColor}
|
||||
color={selectedBadgeColor}
|
||||
className="size-10 shrink-0 rounded-md border border-border/70 bg-muted/30"
|
||||
iconClassName="size-5"
|
||||
/>
|
||||
@@ -135,22 +140,39 @@ export function RepositoryIconPicker({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{REPO_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => updateRepo(repo.id, { badgeColor: color })}
|
||||
className={cn(
|
||||
'size-7 rounded-[4px] transition-all',
|
||||
repo.badgeColor === color
|
||||
? 'ring-2 ring-foreground ring-offset-2 ring-offset-background'
|
||||
: 'hover:ring-1 hover:ring-muted-foreground hover:ring-offset-2 hover:ring-offset-background'
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
title={color}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">Color</Label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{REPO_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => setBadgeColor(color)}
|
||||
aria-label={`Use ${color} repo color`}
|
||||
aria-pressed={selectedBadgeColor === color}
|
||||
className={cn(
|
||||
'size-7 rounded-[4px] outline-none transition-all focus-visible:ring-[3px] focus-visible:ring-ring/50',
|
||||
selectedBadgeColor === color
|
||||
? 'ring-2 ring-foreground ring-offset-2 ring-offset-background'
|
||||
: 'hover:ring-1 hover:ring-muted-foreground hover:ring-offset-2 hover:ring-offset-background'
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
<ColorPicker
|
||||
value={selectedBadgeColor}
|
||||
onChange={setBadgeColor}
|
||||
label={
|
||||
isPresetBadgeColor
|
||||
? 'Choose custom repo color'
|
||||
: `Custom repo color ${selectedBadgeColor}`
|
||||
}
|
||||
selected={!isPresetBadgeColor}
|
||||
triggerLabel="Custom"
|
||||
showHexInTrigger={!isPresetBadgeColor}
|
||||
className="h-7 px-2"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue={initialTab} className="gap-3">
|
||||
|
||||
@@ -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) ? (
|
||||
<section key="identity" className="space-y-8">
|
||||
<section key="identity" className="relative space-y-8">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-1 pr-12">
|
||||
<h3 className="text-sm font-semibold">Identity</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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}
|
||||
>
|
||||
<Button
|
||||
variant={confirmingRemove === repo.id ? 'destructive' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleRemoveProject(repo.id)}
|
||||
onBlur={() => setConfirmingRemove(null)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
{confirmingRemove === repo.id ? 'Confirm Remove' : 'Remove Project'}
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant={confirmingRemove === repo.id ? 'destructive' : 'outline'}
|
||||
size="icon-sm"
|
||||
onClick={() => handleRemoveProject(repo.id)}
|
||||
onBlur={() => setConfirmingRemove(null)}
|
||||
aria-label={removeProjectLabel}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{removeProjectLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</SearchableSetting>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export function getRepositoryPaneSearchEntries(repo: Repo): SettingsSearchEntry[
|
||||
'project icon',
|
||||
'repository icon',
|
||||
'color',
|
||||
'hex',
|
||||
'badge',
|
||||
'emoji',
|
||||
'favicon'
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(
|
||||
<ColorPicker value="#ABCDEF" onChange={vi.fn()} label="Custom repo color" />
|
||||
)
|
||||
|
||||
expect(html).toContain('aria-label="Custom repo color"')
|
||||
expect(html).toContain('#abcdef')
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
<Popover defaultOpen={defaultOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-8 gap-2 px-2.5',
|
||||
selected ? 'ring-2 ring-foreground ring-offset-2 ring-offset-background' : null,
|
||||
className
|
||||
)}
|
||||
aria-label={label}
|
||||
aria-pressed={selected}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="size-4 rounded-[4px] border border-border/70"
|
||||
style={{ backgroundColor: currentColor }}
|
||||
/>
|
||||
{triggerLabel ? <span className="text-xs">{triggerLabel}</span> : null}
|
||||
{shouldShowTriggerHex ? (
|
||||
<span className="font-mono text-xs uppercase">{currentColor}</span>
|
||||
) : null}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-64 p-3">
|
||||
<div className="space-y-3">
|
||||
<HexColorPicker
|
||||
color={swatchColor}
|
||||
onChange={updateColor}
|
||||
aria-label={`${label} picker`}
|
||||
className="[&_.react-colorful__hue]:rounded-b-md [&_.react-colorful__interactive:focus_.react-colorful__pointer]:ring-[3px] [&_.react-colorful__interactive:focus_.react-colorful__pointer]:ring-ring/50 [&_.react-colorful__pointer]:border-popover"
|
||||
style={{ width: '100%', height: 180 }}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label htmlFor={inputId}>Hex</Label>
|
||||
<span className="font-mono text-xs uppercase text-muted-foreground">{swatchColor}</span>
|
||||
</div>
|
||||
<Input
|
||||
id={inputId}
|
||||
value={draft}
|
||||
onFocus={() =>
|
||||
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 ? <p className="text-xs text-destructive">Invalid hex color.</p> : null}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user