Files
orca/src/shared/repo-icon.ts
T
Trevin ChowandNeil abfe9bd329 Default repo avatars to the GitHub upstream owner and flag forks (#4522)
* Default repo avatars to the GitHub upstream owner and flag forks

Make the GitHub owner avatar the default repo icon and surface it as the
primary choice in the icon picker (the "Image" tab becomes "Avatar",
moves first, and opens by default). For forks, resolve the upstream/
parent owner so the avatar reflects the source repo instead of the
personal fork, and show a fork indicator (GitFork glyph + "Fork of
owner/repo" tooltip) next to the repo in the sidebar and settings.

Fork detection prefers the offline `upstream` remote, falling back to a
`gh repo view --json isFork,parent` lookup; the resolved upstream is
stored on the repo. Existing repos self-correct via a one-time startup
backfill (local repos) and a lazy backfill when their icon settings open
(SSH repos); new repos resolve at add-time. Reset now restores the
default (re-detecting the upstream) instead of clearing to the Folder
icon.

* Harden repo upstream avatar handling

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-06-02 20:55:35 -07:00

113 lines
3.4 KiB
TypeScript

export type RepoIconImageSource = 'upload' | 'file' | '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 isRepoIconImageSource = (value: string): value is RepoIconImageSource =>
value === 'upload' || value === 'file' || value === 'favicon' || value === 'github'
export 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
}
}
// Why: the GitHub owner avatar is the default repo icon, built the same way in
// main (auto-detect) and renderer (picker); keep the URL and label in one place.
export function githubAvatarIcon(slug: { owner: string; repo: string }): RepoIcon {
return {
type: 'image',
src: `https://github.com/${encodeURIComponent(slug.owner)}.png?size=64`,
source: 'github',
label: `${slug.owner}/${slug.repo}`
}
}
function isSupportedImageSrc(src: string, source: RepoIconImageSource): boolean {
if (source === 'upload' || source === 'file') {
return /^data:image\/png;base64,[A-Za-z0-9+/=\s]+$/i.test(src)
}
let url: URL
try {
url = new URL(src)
} catch {
return false
}
if (url.protocol !== 'https:') {
return false
}
if (source === 'github') {
return url.hostname === 'github.com' && /^\/[^/?#]+\.png$/i.test(url.pathname)
}
return url.hostname === 'www.google.com' && url.pathname === '/s2/favicons'
}
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 (!isRepoIconImageSource(source) || src.length > MAX_REPO_ICON_DATA_URL_LENGTH) {
return undefined
}
if (!isSupportedImageSrc(src, source)) {
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
}