fix: address review findings (#2703)
This commit is contained in:
parent
ace241c43f
commit
3f6940a746
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -2043,6 +2043,7 @@ export class Store {
|
|||
Repo,
|
||||
| 'displayName'
|
||||
| 'badgeColor'
|
||||
| 'repoIcon'
|
||||
| 'hookSettings'
|
||||
| 'worktreeBaseRef'
|
||||
| 'kind'
|
||||
|
|
|
|||
|
|
@ -5294,6 +5294,7 @@ export class OrcaRuntimeService {
|
|||
Repo,
|
||||
| 'displayName'
|
||||
| 'badgeColor'
|
||||
| 'repoIcon'
|
||||
| 'hookSettings'
|
||||
| 'worktreeBaseRef'
|
||||
| 'kind'
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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> =>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ type RepoUpdate = Partial<
|
|||
Repo,
|
||||
| 'displayName'
|
||||
| 'badgeColor'
|
||||
| 'repoIcon'
|
||||
| 'hookSettings'
|
||||
| 'worktreeBaseRef'
|
||||
| 'kind'
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue