From 3f6940a746bbb44b4b3328fb6d299567df0bdd00 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 23 May 2026 13:11:08 -0700 Subject: [PATCH] fix: address review findings (#2703) --- src/main/ipc/repos.ts | 10 + src/main/ipc/shell.ts | 41 ++- src/main/persistence.ts | 1 + src/main/runtime/orca-runtime.ts | 1 + src/main/runtime/rpc/methods/repo.ts | 5 + src/preload/api-types.ts | 2 + src/preload/index.ts | 3 + .../src/components/repo/repo-icon.tsx | 102 +++++++ .../settings/RepositoryIconPicker.tsx | 270 ++++++++++++++++++ .../components/settings/RepositoryPane.tsx | 80 +++--- .../settings/repository-settings-targets.ts | 4 +- .../src/components/sidebar/WorktreeList.tsx | 23 +- .../sidebar/worktree-list-groups.test.ts | 2 +- src/renderer/src/store/slices/repos.ts | 1 + src/renderer/src/web/web-preload-api.ts | 1 + src/shared/repo-icon.test.ts | 49 ++++ src/shared/repo-icon.ts | 68 +++++ src/shared/types.ts | 2 + 18 files changed, 615 insertions(+), 50 deletions(-) create mode 100644 src/renderer/src/components/repo/repo-icon.tsx create mode 100644 src/renderer/src/components/settings/RepositoryIconPicker.tsx create mode 100644 src/shared/repo-icon.test.ts create mode 100644 src/shared/repo-icon.ts diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index fb4528d42..395ece5e1 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -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 && diff --git a/src/main/ipc/shell.ts b/src/main/ipc/shell.ts index 0edbbc49b..4712828a5 100644 --- a/src/main/ipc/shell.ts +++ b/src/main/ipc/shell.ts @@ -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 = { + '.png': 'image/png', + '.svg': 'image/svg+xml' +} + async function pathExists(pathValue: string): Promise { 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 => { const result = await dialog.showOpenDialog({ properties: ['openFile'], diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 7f4629b97..65497208a 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -2043,6 +2043,7 @@ export class Store { Repo, | 'displayName' | 'badgeColor' + | 'repoIcon' | 'hookSettings' | 'worktreeBaseRef' | 'kind' diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 79d2b7f9b..f3fdb40a1 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -5294,6 +5294,7 @@ export class OrcaRuntimeService { Repo, | 'displayName' | 'badgeColor' + | 'repoIcon' | 'hookSettings' | 'worktreeBaseRef' | 'kind' diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts index 4ec5f644e..951156eea 100644 --- a/src/main/runtime/rpc/methods/repo.ts +++ b/src/main/runtime/rpc/methods/repo.ts @@ -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(), diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index f9ea050c9..ed73a7ade 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 pickAttachment: () => Promise pickImage: () => Promise + pickRepoIconImage: () => Promise<{ dataUrl: string; fileName: string } | null> pickAudio: () => Promise pickDirectory: (args: { defaultPath?: string }) => Promise copyFile: (args: { srcPath: string; destPath: string }) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 913411566..862267be3 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1501,6 +1501,9 @@ const api = { pickImage: (): Promise => ipcRenderer.invoke('shell:pickImage'), + pickRepoIconImage: (): Promise<{ dataUrl: string; fileName: string } | null> => + ipcRenderer.invoke('shell:pickRepoIconImage'), + pickAudio: (): Promise => ipcRenderer.invoke('shell:pickAudio'), pickDirectory: (args: { defaultPath?: string }): Promise => diff --git a/src/renderer/src/components/repo/repo-icon.tsx b/src/renderer/src/components/repo/repo-icon.tsx new file mode 100644 index 000000000..69cc2c79b --- /dev/null +++ b/src/renderer/src/components/repo/repo-icon.tsx @@ -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 ( + + + + ) + } + + if (repoIcon?.type === 'emoji') { + return ( + + ) + } + + const Icon = getRepoLucideIcon(repoIcon?.type === 'lucide' ? repoIcon.name : 'Folder') + return ( + + + + ) +} diff --git a/src/renderer/src/components/settings/RepositoryIconPicker.tsx b/src/renderer/src/components/settings/RepositoryIconPicker.tsx new file mode 100644 index 000000000..5931d3afe --- /dev/null +++ b/src/renderer/src/components/settings/RepositoryIconPicker.tsx @@ -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) => 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 ( +
+
+ +
+ +
{currentIconLabel}
+
+ +
+ +
+ {REPO_COLORS.map((color) => ( +
+ + + + + Icon + + + Emoji + + + Image + + + + +
+ {REPO_LUCIDE_ICON_OPTIONS.map((option) => ( + + + + + + {option.label} + + + ))} +
+
+ + + {EMOJI_OPTIONS.map((emoji) => ( + + ))} + + + +
+ + +
+
+ setWebsite(event.target.value)} + placeholder="example.com" + className="h-9 text-sm" + /> + +
+

PNG/SVG uploads must be 256KB or smaller.

+
+
+
+ ) +} diff --git a/src/renderer/src/components/settings/RepositoryPane.tsx b/src/renderer/src/components/settings/RepositoryPane.tsx index 143522df1..1a4b8ff5d 100644 --- a/src/renderer/src/components/settings/RepositoryPane.tsx +++ b/src/renderer/src/components/settings/RepositoryPane.tsx @@ -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({ + + + updateRepo(repo.id, { + displayName: e.target.value + }) + } + className="h-9 text-sm" + /> + + + - -
- - updateRepo(repo.id, { - displayName: e.target.value - }) - } - className="h-9 flex-1 text-sm" - /> -
- {REPO_COLORS.map((color) => ( -
-
+
{!isFolder ? ( diff --git a/src/renderer/src/components/settings/repository-settings-targets.ts b/src/renderer/src/components/settings/repository-settings-targets.ts index 783cfa76c..6a6b7ceae 100644 --- a/src/renderer/src/components/settings/repository-settings-targets.ts +++ b/src/renderer/src/components/settings/repository-settings-targets.ts @@ -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` } diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 83fd63393..96dae75de 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -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.repo ? ( + + ) : ( + + )} ) : 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) ) } }} > - - Change Project Color + + Change Project Icon {row.repo && isGitRepoKind(row.repo) ? ( { expect(source).toContain('resolveRepoGroupHeaderColor({') expect(source).toContain('headerKey: row.key') - expect(source).toContain('style={repoHeaderColor ? { color: repoHeaderColor } : undefined}') + expect(source).toContain('color={repoHeaderColor}') }) }) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 99b74771f..bb5c1d295 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -18,6 +18,7 @@ type RepoUpdate = Partial< Repo, | 'displayName' | 'badgeColor' + | 'repoIcon' | 'hookSettings' | 'worktreeBaseRef' | 'kind' diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index d0a6310c9..a132cfe6e 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1780,6 +1780,7 @@ function createShellApi(): NonNullable['shell']> { }, pickAttachment: () => Promise.resolve(null), pickImage: () => Promise.resolve(null), + pickRepoIconImage: () => Promise.resolve(null), pickAudio: () => Promise.resolve(null), pickDirectory: () => Promise.resolve(null), copyFile: () => Promise.resolve() diff --git a/src/shared/repo-icon.test.ts b/src/shared/repo-icon.test.ts new file mode 100644 index 000000000..072da3862 --- /dev/null +++ b/src/shared/repo-icon.test.ts @@ -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() + }) +}) diff --git a/src/shared/repo-icon.ts b/src/shared/repo-icon.ts new file mode 100644 index 000000000..b48300a13 --- /dev/null +++ b/src/shared/repo-icon.ts @@ -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 + 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 +} diff --git a/src/shared/types.ts b/src/shared/types.ts index a2e17a9a4..49e43ed3c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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