fix: address review findings (#2703)

This commit is contained in:
Jinjing 2026-05-23 13:11:08 -07:00 committed by GitHub
parent ace241c43f
commit 3f6940a746
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 615 additions and 50 deletions

View File

@ -13,6 +13,7 @@ import type {
} from '../../shared/types'
import { isFolderRepo } from '../../shared/repo-kind'
import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants'
import { sanitizeRepoIcon } from '../../shared/repo-icon'
import { invalidateAuthorizedRootsCache } from './filesystem-auth'
import type { ChildProcess } from 'child_process'
import { access, mkdir, readdir, rm } from 'fs/promises'
@ -476,6 +477,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
Repo,
| 'displayName'
| 'badgeColor'
| 'repoIcon'
| 'hookSettings'
| 'worktreeBaseRef'
| 'kind'
@ -515,6 +517,14 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
delete updates.symlinkPaths
}
}
if ('repoIcon' in updates) {
const repoIcon = sanitizeRepoIcon(updates.repoIcon)
if (repoIcon === undefined) {
delete updates.repoIcon
} else {
updates.repoIcon = repoIcon
}
}
if (
'externalWorktreeVisibility' in updates &&
updates.externalWorktreeVisibility !== undefined &&

View File

@ -1,14 +1,20 @@
import { ipcMain, shell, dialog } from 'electron'
import { spawn } from 'node:child_process'
import { constants, copyFile, stat } from 'node:fs/promises'
import { basename, isAbsolute, normalize, win32 } from 'node:path'
import { constants, copyFile, readFile, stat } from 'node:fs/promises'
import { basename, extname, isAbsolute, normalize, win32 } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { ShellOpenLocalPathResult } from '../../shared/shell-open-types'
import { MAX_REPO_ICON_UPLOAD_BYTES } from '../../shared/repo-icon'
import { resolveCliCommand } from '../codex-cli/command'
import { getSpawnArgsForWindows } from '../win32-utils'
export const EXTERNAL_EDITOR_CLI_COMMAND = 'code'
const REPO_ICON_IMAGE_MIME_TYPES: Record<string, string> = {
'.png': 'image/png',
'.svg': 'image/svg+xml'
}
async function pathExists(pathValue: string): Promise<boolean> {
try {
await stat(pathValue)
@ -237,6 +243,37 @@ export function registerShellHandlers(): void {
return result.filePaths[0]
})
ipcMain.handle(
'shell:pickRepoIconImage',
async (): Promise<{ dataUrl: string; fileName: string } | null> => {
const result = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'Repo icon images', extensions: ['png', 'svg'] }]
})
if (result.canceled || result.filePaths.length === 0) {
return null
}
const filePath = result.filePaths[0]
const extension = extname(filePath).toLowerCase()
const mimeType = REPO_ICON_IMAGE_MIME_TYPES[extension]
if (!mimeType) {
throw new Error('Repo icons must be PNG or SVG files.')
}
const stats = await stat(filePath)
if (stats.size > MAX_REPO_ICON_UPLOAD_BYTES) {
throw new Error('Repo icon image must be 256KB or smaller.')
}
const buffer = await readFile(filePath)
return {
dataUrl: `data:${mimeType};base64,${buffer.toString('base64')}`,
fileName: basename(filePath)
}
}
)
ipcMain.handle('shell:pickAudio', async (): Promise<string | null> => {
const result = await dialog.showOpenDialog({
properties: ['openFile'],

View File

@ -2043,6 +2043,7 @@ export class Store {
Repo,
| 'displayName'
| 'badgeColor'
| 'repoIcon'
| 'hookSettings'
| 'worktreeBaseRef'
| 'kind'

View File

@ -5294,6 +5294,7 @@ export class OrcaRuntimeService {
Repo,
| 'displayName'
| 'badgeColor'
| 'repoIcon'
| 'hookSettings'
| 'worktreeBaseRef'
| 'kind'

View File

@ -1,6 +1,7 @@
import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
import { sanitizeRepoIcon } from '../../../../shared/repo-icon'
const RepoSelector = z.object({
repo: requiredString('Missing repo selector')
@ -31,6 +32,10 @@ const RepoUpdate = RepoSelector.extend({
updates: z.object({
displayName: OptionalString,
badgeColor: OptionalString,
repoIcon: z
.unknown()
.transform((value) => sanitizeRepoIcon(value))
.optional(),
hookSettings: z.unknown().optional(),
worktreeBaseRef: OptionalString,
kind: z.enum(['git', 'folder']).optional(),

View File

@ -621,6 +621,7 @@ export type PreloadApi = {
Repo,
| 'displayName'
| 'badgeColor'
| 'repoIcon'
| 'hookSettings'
| 'worktreeBaseRef'
| 'kind'
@ -1355,6 +1356,7 @@ export type PreloadApi = {
pathExists: (path: string) => Promise<boolean>
pickAttachment: () => Promise<string | null>
pickImage: () => Promise<string | null>
pickRepoIconImage: () => Promise<{ dataUrl: string; fileName: string } | null>
pickAudio: () => Promise<string | null>
pickDirectory: (args: { defaultPath?: string }) => Promise<string | null>
copyFile: (args: { srcPath: string; destPath: string }) => Promise<void>

View File

@ -1501,6 +1501,9 @@ const api = {
pickImage: (): Promise<string | null> => ipcRenderer.invoke('shell:pickImage'),
pickRepoIconImage: (): Promise<{ dataUrl: string; fileName: string } | null> =>
ipcRenderer.invoke('shell:pickRepoIconImage'),
pickAudio: (): Promise<string | null> => ipcRenderer.invoke('shell:pickAudio'),
pickDirectory: (args: { defaultPath?: string }): Promise<string | null> =>

View File

@ -0,0 +1,102 @@
import React from 'react'
import {
Bot,
Box,
Braces,
Briefcase,
Building2,
Code2,
Cpu,
Database,
Folder,
Gauge,
Globe,
Layers,
Package,
Palette,
Rocket,
Server,
Shapes,
Sparkles,
SquareTerminal,
Wrench,
type LucideIcon
} from 'lucide-react'
import type { RepoIcon } from '../../../../shared/repo-icon'
import { cn } from '@/lib/utils'
export type RepoLucideIconOption = {
name: string
label: string
icon: LucideIcon
}
export const REPO_LUCIDE_ICON_OPTIONS: RepoLucideIconOption[] = [
{ name: 'Folder', label: 'Folder', icon: Folder },
{ name: 'Code2', label: 'Code', icon: Code2 },
{ name: 'SquareTerminal', label: 'Terminal', icon: SquareTerminal },
{ name: 'Bot', label: 'Agent', icon: Bot },
{ name: 'Package', label: 'Package', icon: Package },
{ name: 'Database', label: 'Database', icon: Database },
{ name: 'Globe', label: 'Web', icon: Globe },
{ name: 'Server', label: 'Server', icon: Server },
{ name: 'Cpu', label: 'Compute', icon: Cpu },
{ name: 'Layers', label: 'Layers', icon: Layers },
{ name: 'Braces', label: 'API', icon: Braces },
{ name: 'Rocket', label: 'Launch', icon: Rocket },
{ name: 'Wrench', label: 'Tools', icon: Wrench },
{ name: 'Briefcase', label: 'Work', icon: Briefcase },
{ name: 'Building2', label: 'Company', icon: Building2 },
{ name: 'Palette', label: 'Design', icon: Palette },
{ name: 'Gauge', label: 'Metrics', icon: Gauge },
{ name: 'Sparkles', label: 'AI', icon: Sparkles },
{ name: 'Shapes', label: 'Shapes', icon: Shapes },
{ name: 'Box', label: 'Box', icon: Box }
]
export function getRepoLucideIcon(name: string | null | undefined): LucideIcon {
return REPO_LUCIDE_ICON_OPTIONS.find((option) => option.name === name)?.icon ?? Folder
}
export function RepoIconGlyph({
repoIcon,
className,
iconClassName,
color
}: {
repoIcon: RepoIcon | null | undefined
className?: string
iconClassName?: string
color?: string
}): React.JSX.Element {
if (repoIcon?.type === 'image') {
return (
<span className={cn('inline-flex items-center justify-center overflow-hidden', className)}>
<img
src={repoIcon.src}
alt=""
className={cn('size-full object-contain', iconClassName)}
draggable={false}
/>
</span>
)
}
if (repoIcon?.type === 'emoji') {
return (
<span
className={cn('inline-flex items-center justify-center leading-none', className)}
aria-hidden="true"
>
<span className={cn('text-[0.9em]', iconClassName)}>{repoIcon.emoji}</span>
</span>
)
}
const Icon = getRepoLucideIcon(repoIcon?.type === 'lucide' ? repoIcon.name : 'Folder')
return (
<span className={cn('inline-flex items-center justify-center', className)}>
<Icon className={iconClassName} style={color ? { color } : undefined} />
</span>
)
}

View File

@ -0,0 +1,270 @@
import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import { Github, Image, Link2, RotateCcw } from 'lucide-react'
import type { Repo } from '../../../../shared/types'
import type { RepoIcon } from '../../../../shared/repo-icon'
import { REPO_COLORS } from '../../../../shared/constants'
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 { RepoIconGlyph, REPO_LUCIDE_ICON_OPTIONS } from '../repo/repo-icon'
import { cn } from '@/lib/utils'
import { useAppStore } from '@/store'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
const EMOJI_OPTIONS = ['🚀', '✨', '💻', '🧠', '📦', '🔧', '🎨', '🌐', '📊', '🔒', '⚡', '✅']
function faviconUrlFromWebsite(rawUrl: string): string | null {
const trimmed = rawUrl.trim()
if (!trimmed) {
return null
}
try {
const url = new URL(trimmed.includes('://') ? trimmed : `https://${trimmed}`)
if (!['http:', 'https:'].includes(url.protocol) || !url.hostname) {
return null
}
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(url.hostname)}&sz=64`
} catch {
return null
}
}
export function RepositoryIconPicker({
repo,
updateRepo
}: {
repo: Repo
updateRepo: (repoId: string, updates: Partial<Repo>) => void
}): React.JSX.Element {
const [website, setWebsite] = useState('')
const [loadingGitHub, setLoadingGitHub] = useState(false)
const activeRuntimeEnvironmentId = useAppStore(
(state) => state.settings?.activeRuntimeEnvironmentId ?? null
)
const selectedLucideName = repo.repoIcon?.type === 'lucide' ? repo.repoIcon.name : 'Folder'
const selectedEmoji = repo.repoIcon?.type === 'emoji' ? repo.repoIcon.emoji : ''
const runtimeTarget = useMemo(
() => getActiveRuntimeTarget({ activeRuntimeEnvironmentId }),
[activeRuntimeEnvironmentId]
)
const currentIconLabel = useMemo(() => {
if (repo.repoIcon?.type === 'image') {
return repo.repoIcon.label ?? 'Custom image'
}
if (repo.repoIcon?.type === 'emoji') {
return `${repo.repoIcon.emoji} emoji`
}
const label =
REPO_LUCIDE_ICON_OPTIONS.find((option) => option.name === selectedLucideName)?.label ??
'Folder'
return `${label} icon with repo color`
}, [repo.repoIcon, selectedLucideName])
const setIcon = (repoIcon: RepoIcon | null) => updateRepo(repo.id, { repoIcon })
const handleUploadImage = async () => {
try {
const result = await window.api.shell.pickRepoIconImage()
if (!result) {
return
}
setIcon({
type: 'image',
src: result.dataUrl,
source: 'upload',
label: result.fileName
})
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to import repo icon')
}
}
const handleUseWebsiteFavicon = () => {
const src = faviconUrlFromWebsite(website)
if (!src) {
toast.error('Enter a valid website URL.')
return
}
setIcon({ type: 'image', src, source: 'favicon', label: 'Website favicon' })
}
const handleUseGitHubAvatar = async () => {
setLoadingGitHub(true)
try {
// Why: SSH runtime repos only exist remotely, so resolve their git remotes
// through the active runtime instead of the local Electron main process.
const slug =
runtimeTarget.kind === 'environment'
? await callRuntimeRpc<{ owner: string; repo: string } | null>(
runtimeTarget,
'github.repoSlug',
{ repo: repo.id },
{ timeoutMs: 30_000 }
)
: await window.api.gh.repoSlug({ repoPath: repo.path, repoId: repo.id })
if (!slug) {
toast.error('No GitHub remote found for this repo.')
return
}
setIcon({
type: 'image',
src: `https://github.com/${encodeURIComponent(slug.owner)}.png?size=64`,
source: 'github',
label: `${slug.owner}/${slug.repo}`
})
} catch {
toast.error('Failed to resolve the GitHub repo.')
} finally {
setLoadingGitHub(false)
}
}
return (
<div className="space-y-3">
<div className="flex items-center gap-3">
<RepoIconGlyph
repoIcon={repo.repoIcon}
color={repo.badgeColor}
className="size-10 shrink-0 rounded-md border border-border/70 bg-muted/30"
iconClassName="size-5"
/>
<div className="min-w-0 flex-1">
<Label className="text-sm font-semibold">Repo Icon</Label>
<div className="mt-1 truncate text-xs text-muted-foreground">{currentIconLabel}</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="gap-2"
onClick={() => setIcon(null)}
>
<RotateCcw className="size-3.5" />
Reset
</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-full 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>
<Tabs defaultValue="icon" className="gap-3">
<TabsList variant="line" className="h-8">
<TabsTrigger value="icon" className="h-7 text-xs">
Icon
</TabsTrigger>
<TabsTrigger value="emoji" className="h-7 text-xs">
Emoji
</TabsTrigger>
<TabsTrigger value="image" className="h-7 text-xs">
Image
</TabsTrigger>
</TabsList>
<TabsContent value="icon" className="space-y-3">
<div className="grid grid-cols-10 gap-1.5">
{REPO_LUCIDE_ICON_OPTIONS.map((option) => (
<Tooltip key={option.name}>
<TooltipTrigger asChild>
<Button
type="button"
variant={selectedLucideName === option.name ? 'secondary' : 'ghost'}
size="icon-xs"
className="size-8"
onClick={() => setIcon({ type: 'lucide', name: option.name })}
aria-label={`Use ${option.label} repo icon`}
>
<option.icon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{option.label}
</TooltipContent>
</Tooltip>
))}
</div>
</TabsContent>
<TabsContent value="emoji" className="grid grid-cols-12 gap-1.5">
{EMOJI_OPTIONS.map((emoji) => (
<Button
key={emoji}
type="button"
variant={selectedEmoji === emoji ? 'secondary' : 'ghost'}
size="icon-xs"
className="size-8 text-base"
onClick={() => setIcon({ type: 'emoji', emoji })}
aria-label={`Use ${emoji} repo icon`}
>
{emoji}
</Button>
))}
</TabsContent>
<TabsContent value="image" className="space-y-3">
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="gap-2"
onClick={handleUploadImage}
>
<Image className="size-3.5" />
Upload PNG/SVG
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="gap-2"
disabled={loadingGitHub}
onClick={() => void handleUseGitHubAvatar()}
>
<Github className="size-3.5" />
GitHub Avatar
</Button>
</div>
<div className="flex gap-2">
<Input
value={website}
onChange={(event) => setWebsite(event.target.value)}
placeholder="example.com"
className="h-9 text-sm"
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-9 gap-2"
onClick={handleUseWebsiteFavicon}
>
<Link2 className="size-3.5" />
Favicon
</Button>
</div>
<p className="text-xs text-muted-foreground">PNG/SVG uploads must be 256KB or smaller.</p>
</TabsContent>
</Tabs>
</div>
)
}

View File

@ -1,7 +1,6 @@
import { useState } from 'react'
import type { OrcaHooks, Repo, RepoHookSettings } from '../../../../shared/types'
import { getRepoKindLabel, isFolderRepo } from '../../../../shared/repo-kind'
import { REPO_COLORS } from '../../../../shared/constants'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
@ -15,7 +14,8 @@ import { SparsePresetSettingsSection } from './SparsePresetSettingsSection'
import { SearchableSetting } from './SearchableSetting'
import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search'
import { useAppStore } from '../../store'
import { getRepositoryBadgeColorSectionId } from './repository-settings-targets'
import { getRepositoryIconSectionId } from './repository-settings-targets'
import { RepositoryIconPicker } from './RepositoryIconPicker'
type RepositoryPaneProps = {
repo: Repo
@ -36,9 +36,17 @@ export function getRepositoryPaneSearchEntries(repo: Repo): SettingsSearchEntry[
keywords: [repo.displayName, repo.path, 'project name', 'repository name']
},
{
title: 'Badge Color',
description: 'Project color used in the sidebar and tabs.',
keywords: [repo.displayName, 'color', 'badge']
title: 'Project Icon',
description: 'Project icon and color used in the sidebar and tabs.',
keywords: [
repo.displayName,
'project icon',
'repository icon',
'color',
'badge',
'emoji',
'favicon'
]
},
...(isFolder
? []
@ -215,7 +223,9 @@ export function RepositoryPane({
const allEntries = getRepositoryPaneSearchEntries(repo)
const identityEntries = allEntries.filter((entry) =>
['Display Name', 'Badge Color', 'Default Worktree Base', 'Remove Project'].includes(entry.title)
['Display Name', 'Project Icon', 'Default Worktree Base', 'Remove Project'].includes(
entry.title
)
)
const sparsePresetEntries = allEntries.filter((entry) =>
['Sparse Checkout Presets'].includes(entry.title)
@ -247,7 +257,7 @@ export function RepositoryPane({
/>
) : null
// Why: Identity (name, color, base ref) stays at the top so it's the first
// Why: Identity (name, icon, base ref) stays at the top so it's the first
// thing a user sees. Setup commands follow immediately because they're the
// most-edited surface and should beat MCP/symlinks/sparse-presets.
const visibleSections = [
@ -289,44 +299,38 @@ export function RepositoryPane({
<SearchableSetting
title="Display Name"
description="Project-specific display details for the sidebar and tabs."
keywords={[repo.displayName, repo.path, 'project name', 'repository name']}
className="space-y-2"
>
<Label className="text-sm font-semibold">Display Name</Label>
<Input
value={repo.displayName}
onChange={(e) =>
updateRepo(repo.id, {
displayName: e.target.value
})
}
className="h-9 text-sm"
/>
</SearchableSetting>
<SearchableSetting
title="Project Icon"
description="Project icon and color used in the sidebar and tabs."
keywords={[
repo.displayName,
repo.path,
'project name',
'repository name',
'project icon',
'repository icon',
'color',
'badge'
'badge',
'emoji',
'favicon'
]}
className="space-y-2"
id={getRepositoryBadgeColorSectionId(repo.id)}
id={getRepositoryIconSectionId(repo.id)}
>
<Label className="text-sm font-semibold">Display Name</Label>
<div className="flex items-center gap-3">
<Input
value={repo.displayName}
onChange={(e) =>
updateRepo(repo.id, {
displayName: e.target.value
})
}
className="h-9 flex-1 text-sm"
/>
<div className="flex flex-wrap gap-2">
{REPO_COLORS.map((color) => (
<button
key={color}
onClick={() => updateRepo(repo.id, { badgeColor: color })}
className={`size-7 rounded-full 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>
</div>
<RepositoryIconPicker repo={repo} updateRepo={updateRepo} />
</SearchableSetting>
{!isFolder ? (

View File

@ -2,6 +2,6 @@ export function getRepositoryLocalCommandsSectionId(repoId: string): string {
return `repo-${repoId}-local-commands`
}
export function getRepositoryBadgeColorSectionId(repoId: string): string {
return `repo-${repoId}-badge-color`
export function getRepositoryIconSectionId(repoId: string): string {
return `repo-${repoId}-icon`
}

View File

@ -11,8 +11,8 @@ import {
CircleX,
Ellipsis,
Eye,
Palette,
Plus,
Shapes,
SlidersHorizontal,
Trash2,
Workflow
@ -129,13 +129,14 @@ import { branchDisplayName } from './WorktreeCardHelpers'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import { getRepoHeaderCreateState } from './repo-header-create-state'
import type { PendingSidebarWorktreeReveal } from '@/store/slices/ui'
import { getRepositoryBadgeColorSectionId } from '@/components/settings/repository-settings-targets'
import { getRepositoryIconSectionId } from '@/components/settings/repository-settings-targets'
import { keybindingMatchesAction } from '../../../../shared/keybindings'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import {
effectiveExternalWorktreeVisibility,
isLegacyRepoForExternalWorktreeVisibility
} from '../../../../shared/worktree-ownership'
import { RepoIconGlyph } from '@/components/repo/repo-icon'
// How long to wait after a sortEpoch bump before actually re-sorting.
// Prevents jarring position shifts when background events (AI starting work,
@ -1917,9 +1918,17 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
'flex size-4 shrink-0 items-center justify-center rounded-[4px]',
repoHeaderColor ? 'text-muted-foreground' : row.tone
)}
style={repoHeaderColor ? { color: repoHeaderColor } : undefined}
>
<row.icon className={row.repo ? 'size-3.5' : 'size-3'} />
{row.repo ? (
<RepoIconGlyph
repoIcon={row.repo.repoIcon}
color={repoHeaderColor}
className="size-4"
iconClassName="size-3.5"
/>
) : (
<row.icon className="size-3" />
)}
</div>
) : null}
@ -1987,13 +1996,13 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
if (row.repo) {
handleOpenRepoSettings(
row.repo.id,
getRepositoryBadgeColorSectionId(row.repo.id)
getRepositoryIconSectionId(row.repo.id)
)
}
}}
>
<Palette className="size-3.5" />
Change Project Color
<Shapes className="size-3.5" />
Change Project Icon
</DropdownMenuItem>
{row.repo && isGitRepoKind(row.repo) ? (
<DropdownMenuItem

View File

@ -677,6 +677,6 @@ describe('WorktreeList header styles', () => {
expect(source).toContain('resolveRepoGroupHeaderColor({')
expect(source).toContain('headerKey: row.key')
expect(source).toContain('style={repoHeaderColor ? { color: repoHeaderColor } : undefined}')
expect(source).toContain('color={repoHeaderColor}')
})
})

View File

@ -18,6 +18,7 @@ type RepoUpdate = Partial<
Repo,
| 'displayName'
| 'badgeColor'
| 'repoIcon'
| 'hookSettings'
| 'worktreeBaseRef'
| 'kind'

View File

@ -1780,6 +1780,7 @@ function createShellApi(): NonNullable<Partial<PreloadApi>['shell']> {
},
pickAttachment: () => Promise.resolve(null),
pickImage: () => Promise.resolve(null),
pickRepoIconImage: () => Promise.resolve(null),
pickAudio: () => Promise.resolve(null),
pickDirectory: () => Promise.resolve(null),
copyFile: () => Promise.resolve()

View File

@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { sanitizeRepoIcon } from './repo-icon'
describe('sanitizeRepoIcon', () => {
it('accepts lucide, emoji, and supported image icons', () => {
expect(sanitizeRepoIcon({ type: 'lucide', name: 'Folder' })).toEqual({
type: 'lucide',
name: 'Folder'
})
expect(sanitizeRepoIcon({ type: 'emoji', emoji: '🚀' })).toEqual({
type: 'emoji',
emoji: '🚀'
})
expect(
sanitizeRepoIcon({
type: 'image',
src: 'https://github.com/stablyai.png?size=64',
source: 'github',
label: 'stablyai/orca'
})
).toEqual({
type: 'image',
src: 'https://github.com/stablyai.png?size=64',
source: 'github',
label: 'stablyai/orca'
})
})
it('keeps null as an explicit reset', () => {
expect(sanitizeRepoIcon(null)).toBeNull()
})
it('rejects unsupported image urls and oversized payloads', () => {
expect(
sanitizeRepoIcon({
type: 'image',
src: 'javascript:alert(1)',
source: 'favicon'
})
).toBeUndefined()
expect(
sanitizeRepoIcon({
type: 'image',
src: `data:image/png;base64,${'a'.repeat(401 * 1024)}`,
source: 'upload'
})
).toBeUndefined()
})
})

68
src/shared/repo-icon.ts Normal file
View File

@ -0,0 +1,68 @@
export type RepoIconImageSource = 'upload' | 'favicon' | 'github'
export type RepoIcon =
| { type: 'lucide'; name: string }
| { type: 'emoji'; emoji: string }
| { type: 'image'; src: string; source: RepoIconImageSource; label?: string }
export const MAX_REPO_ICON_UPLOAD_BYTES = 256 * 1024
export const MAX_REPO_ICON_DATA_URL_LENGTH = 400 * 1024
const LUCIDE_ICON_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9]*$/
const IMAGE_SOURCE_IDS = new Set(['upload', 'favicon', 'github'])
function isSupportedImageSrc(src: string): boolean {
return (
/^https:\/\/[^\s]+$/i.test(src) ||
/^data:image\/(?:png|svg\+xml);base64,[A-Za-z0-9+/=\s]+$/i.test(src)
)
}
export function sanitizeRepoIcon(value: unknown): RepoIcon | null | undefined {
if (value === undefined) {
return undefined
}
if (value === null) {
return null
}
if (!value || typeof value !== 'object') {
return undefined
}
const candidate = value as Record<string, unknown>
if (candidate.type === 'lucide') {
const name = typeof candidate.name === 'string' ? candidate.name.trim() : ''
if (!LUCIDE_ICON_NAME_PATTERN.test(name) || name.length > 40) {
return undefined
}
return { type: 'lucide', name }
}
if (candidate.type === 'emoji') {
const emoji = typeof candidate.emoji === 'string' ? candidate.emoji.trim() : ''
if (!emoji || emoji.length > 16) {
return undefined
}
return { type: 'emoji', emoji }
}
if (candidate.type === 'image') {
const src = typeof candidate.src === 'string' ? candidate.src.trim() : ''
const source = typeof candidate.source === 'string' ? candidate.source : ''
if (!IMAGE_SOURCE_IDS.has(source) || src.length > MAX_REPO_ICON_DATA_URL_LENGTH) {
return undefined
}
if (!isSupportedImageSrc(src)) {
return undefined
}
const label = typeof candidate.label === 'string' ? candidate.label.trim().slice(0, 80) : ''
return {
type: 'image',
src,
source: source as RepoIconImageSource,
...(label ? { label } : {})
}
}
return undefined
}

View File

@ -15,6 +15,7 @@ import type { TaskProvider } from './task-providers'
import type { FeatureTipId } from './feature-tips'
import type { GitBranchChangeStatus } from './git-status-types'
import type { KeybindingOverrides, TerminalShortcutPolicy } from './keybindings'
import type { RepoIcon } from './repo-icon'
// Re-exported for backward compat with renderer call sites that import
// `WorkspaceCreateTelemetrySource` from '../../../shared/types'.
@ -75,6 +76,7 @@ export type Repo = {
path: string
displayName: string
badgeColor: string
repoIcon?: RepoIcon | null
addedAt: number
kind?: RepoKind
gitUsername?: string