mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 00:02:54 +00:00
* 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>
113 lines
3.4 KiB
TypeScript
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
|
|
}
|