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>
This commit is contained in:
parent
da3c359a74
commit
abfe9bd329
|
|
@ -93,6 +93,7 @@ vi.mock('./rate-limit', () => ({
|
|||
import {
|
||||
getPRComments,
|
||||
getPRForBranch,
|
||||
getRepoUpstream,
|
||||
getWorkItem,
|
||||
getPullRequestPushTarget,
|
||||
mergePR,
|
||||
|
|
@ -1414,6 +1415,58 @@ describe('getPRForBranch', () => {
|
|||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves a distinct upstream remote as the repo upstream', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'tmchow', repo: 'orca' })
|
||||
getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
|
||||
|
||||
await expect(getRepoUpstream('/repo-root')).resolves.toEqual({
|
||||
owner: 'stablyai',
|
||||
repo: 'orca'
|
||||
})
|
||||
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not treat a same-repository upstream remote as a fork', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'StablyAI', repo: 'Orca' })
|
||||
getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({ isFork: false, parent: null })
|
||||
})
|
||||
|
||||
await expect(getRepoUpstream('/repo-root')).resolves.toBeNull()
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['repo', 'view', 'StablyAI/Orca', '--json', 'isFork,parent'],
|
||||
{ cwd: '/repo-root', timeout: 10_000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('does not mark an upstream-only GitHub remote as a fork', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce(null)
|
||||
|
||||
await expect(getRepoUpstream('/repo-root')).resolves.toBeNull()
|
||||
|
||||
expect(getOwnerRepoForRemoteMock).not.toHaveBeenCalled()
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to the GitHub parent when no upstream remote is configured', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'tmchow', repo: 'orca' })
|
||||
getOwnerRepoForRemoteMock.mockResolvedValueOnce(null)
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
isFork: true,
|
||||
parent: { name: 'orca', owner: { login: 'stablyai' } }
|
||||
})
|
||||
})
|
||||
|
||||
await expect(getRepoUpstream('/repo-root')).resolves.toEqual({
|
||||
owner: 'stablyai',
|
||||
repo: 'orca'
|
||||
})
|
||||
})
|
||||
|
||||
it('probes additional PR repo candidates when the first lookup is not found', async () => {
|
||||
resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({
|
||||
candidates: [
|
||||
|
|
|
|||
|
|
@ -1413,6 +1413,48 @@ export async function getRepoSlug(
|
|||
return getOwnerRepo(repoPath, connectionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a fork's upstream/parent owner/repo, or null when the repo is not a
|
||||
* fork. Why: a fork's `origin` points at the personal copy, so repo identity
|
||||
* (notably the avatar) should prefer the upstream. Fast-paths the `upstream`
|
||||
* remote (offline); otherwise asks the GitHub API for the fork parent. The API
|
||||
* call targets the explicit origin slug, so it works for SSH repos too.
|
||||
* Best-effort: any failure (offline, unauthed, non-GitHub) resolves to null.
|
||||
*/
|
||||
export async function getRepoUpstream(
|
||||
repoPath: string,
|
||||
connectionId?: string | null
|
||||
): Promise<OwnerRepo | null> {
|
||||
const origin = await getOwnerRepo(repoPath, connectionId)
|
||||
if (!origin) {
|
||||
return null
|
||||
}
|
||||
const upstreamRemote = await getOwnerRepoForRemote(repoPath, 'upstream', connectionId)
|
||||
if (upstreamRemote && !sameOwnerRepo(upstreamRemote, origin)) {
|
||||
return upstreamRemote
|
||||
}
|
||||
await acquire()
|
||||
try {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
['repo', 'view', `${origin.owner}/${origin.repo}`, '--json', 'isFork,parent'],
|
||||
// Why: best-effort fork lookup runs at add-time; cap latency so a stalled
|
||||
// gh process can't hold up repo creation.
|
||||
{ ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId)), timeout: 10_000 }
|
||||
)
|
||||
const data = JSON.parse(stdout) as {
|
||||
isFork?: boolean
|
||||
parent?: { name?: string; owner?: { login?: string } } | null
|
||||
}
|
||||
const owner = data.parent?.owner?.login
|
||||
const repo = data.parent?.name
|
||||
return data.isFork && owner && repo ? { owner, repo } : null
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
function classifyCreatePRError(error: unknown): CreateHostedReviewResult {
|
||||
const { stderr, stdout } = extractExecError(error)
|
||||
const message = `${stderr}\n${stdout}`.trim()
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
getPRForBranch,
|
||||
getIssue,
|
||||
getRepoSlug,
|
||||
getRepoUpstream,
|
||||
listIssues,
|
||||
listWorkItems,
|
||||
countWorkItems,
|
||||
|
|
@ -404,6 +405,11 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
|
|||
return getRepoSlug(repo.path, repoConnectionId(repo))
|
||||
})
|
||||
|
||||
ipcMain.handle('gh:repoUpstream', (_event, args: { repoPath: string }) => {
|
||||
const repo = assertRegisteredRepo(args, store)
|
||||
return getRepoUpstream(repo.path, repoConnectionId(repo))
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'gh:prChecks',
|
||||
(
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ import { normalizeSparseDirectories } from './sparse-checkout-directories'
|
|||
import { track } from '../telemetry/client'
|
||||
import { getCohortAtEmit } from '../telemetry/cohort-classifier'
|
||||
import type { RepoMethod } from '../../shared/telemetry-events'
|
||||
import { detectRepoIcon } from '../repo-icon-autodetect'
|
||||
import { detectRepoIconAndUpstream } from '../repo-icon-autodetect'
|
||||
|
||||
// Why: `method` answers "which entry point did the user take?", not "what did
|
||||
// they add?" — so the IPC the renderer invoked IS the method. We never send
|
||||
|
|
@ -606,11 +606,17 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
|||
results.push({ path: repoPath, projectId: existing.id, status: 'already-known' })
|
||||
continue
|
||||
}
|
||||
const detected = await detectRepoIconAndUpstream({
|
||||
repoPath,
|
||||
kind: 'git',
|
||||
connectionId: args.connectionId
|
||||
})
|
||||
const repo: Repo = {
|
||||
id: randomUUID(),
|
||||
path: repoPath,
|
||||
displayName: getRepoName(repoPath),
|
||||
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
||||
...detected,
|
||||
addedAt: Date.now(),
|
||||
kind: 'git',
|
||||
...(args.connectionId ? { connectionId: args.connectionId } : {}),
|
||||
|
|
@ -679,13 +685,13 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
|||
return { repo: existing }
|
||||
}
|
||||
|
||||
const repoIcon = await detectRepoIcon({ repoPath: args.path, kind: repoKind })
|
||||
const detected = await detectRepoIconAndUpstream({ repoPath: args.path, kind: repoKind })
|
||||
const repo: Repo = {
|
||||
id: randomUUID(),
|
||||
path: args.path,
|
||||
displayName: getRepoName(args.path),
|
||||
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
||||
...(repoIcon ? { repoIcon } : {}),
|
||||
...detected,
|
||||
addedAt: Date.now(),
|
||||
kind: repoKind,
|
||||
...(repoKind === 'git'
|
||||
|
|
@ -785,7 +791,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
|||
}
|
||||
}
|
||||
|
||||
const repoIcon = await detectRepoIcon({
|
||||
const detected = await detectRepoIconAndUpstream({
|
||||
repoPath: resolvedPath,
|
||||
kind: repoKind,
|
||||
connectionId: args.connectionId
|
||||
|
|
@ -795,7 +801,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
|||
path: resolvedPath,
|
||||
displayName,
|
||||
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
||||
...(repoIcon ? { repoIcon } : {}),
|
||||
...detected,
|
||||
addedAt: Date.now(),
|
||||
kind: repoKind,
|
||||
connectionId: args.connectionId,
|
||||
|
|
@ -996,13 +1002,13 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
|||
return { repo: raceWinner }
|
||||
}
|
||||
|
||||
const repoIcon = await detectRepoIcon({ repoPath: targetPath, kind: repoKind })
|
||||
const detected = await detectRepoIconAndUpstream({ repoPath: targetPath, kind: repoKind })
|
||||
const repo: Repo = {
|
||||
id: randomUUID(),
|
||||
path: targetPath,
|
||||
displayName: name,
|
||||
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
||||
...(repoIcon ? { repoIcon } : {}),
|
||||
...detected,
|
||||
addedAt: Date.now(),
|
||||
kind: repoKind,
|
||||
...(repoKind === 'git'
|
||||
|
|
@ -1055,6 +1061,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
|||
| 'displayName'
|
||||
| 'badgeColor'
|
||||
| 'repoIcon'
|
||||
| 'upstream'
|
||||
| 'hookSettings'
|
||||
| 'worktreeBaseRef'
|
||||
| 'worktreeBasePath'
|
||||
|
|
@ -1400,13 +1407,13 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
|||
return existing
|
||||
}
|
||||
|
||||
const repoIcon = await detectRepoIcon({ repoPath: clonePath, kind: 'git' })
|
||||
const detected = await detectRepoIconAndUpstream({ repoPath: clonePath, kind: 'git' })
|
||||
const repo: Repo = {
|
||||
id: randomUUID(),
|
||||
path: clonePath,
|
||||
displayName: getRepoName(clonePath),
|
||||
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
||||
...(repoIcon ? { repoIcon } : {}),
|
||||
...detected,
|
||||
addedAt: Date.now(),
|
||||
kind: 'git',
|
||||
externalWorktreeVisibility: 'hide',
|
||||
|
|
|
|||
|
|
@ -1830,6 +1830,29 @@ describe('Store', () => {
|
|||
expect(store.getRepos()[0]!.repoIcon).toBeUndefined()
|
||||
})
|
||||
|
||||
it('updateRepo normalizes and persists repo upstream metadata', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo())
|
||||
|
||||
const updated = store.updateRepo('r1', {
|
||||
upstream: { owner: ' stablyai ', repo: ' orca ' }
|
||||
})
|
||||
expect(updated!.upstream).toEqual({ owner: 'stablyai', repo: 'orca' })
|
||||
|
||||
store.updateRepo('r1', { upstream: null })
|
||||
store.flush()
|
||||
const reloaded = await createStore()
|
||||
expect(reloaded.getRepo('r1')!.upstream).toBeNull()
|
||||
})
|
||||
|
||||
it('getRepo does not expose invalid persisted repo upstream metadata', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo({ upstream: { owner: '', repo: 42 } as never }))
|
||||
|
||||
expect(store.getRepo('r1')!.upstream).toBeUndefined()
|
||||
expect(store.getRepos()[0]!.upstream).toBeUndefined()
|
||||
})
|
||||
|
||||
it('updateRepo returns null for nonexistent id', async () => {
|
||||
const store = await createStore()
|
||||
expect(store.updateRepo('nope', { displayName: 'x' })).toBeNull()
|
||||
|
|
|
|||
|
|
@ -599,8 +599,24 @@ function readLegacySidekickFlag(parsed: PersistedState | undefined): boolean | u
|
|||
return (parsed?.settings as { experimentalSidekick?: boolean } | undefined)?.experimentalSidekick
|
||||
}
|
||||
|
||||
function sanitizeRepoUpstream(value: unknown): Repo['upstream'] | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (value === null) {
|
||||
return null
|
||||
}
|
||||
if (!value || typeof value !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
const candidate = value as { owner?: unknown; repo?: unknown }
|
||||
const owner = typeof candidate.owner === 'string' ? candidate.owner.trim() : ''
|
||||
const repo = typeof candidate.repo === 'string' ? candidate.repo.trim() : ''
|
||||
return owner && repo ? { owner, repo } : undefined
|
||||
}
|
||||
|
||||
function sanitizeRepoUpdatesForPersistence<
|
||||
T extends Partial<Pick<Repo, 'badgeColor' | 'repoIcon' | 'worktreeBasePath'>>
|
||||
T extends Partial<Pick<Repo, 'badgeColor' | 'repoIcon' | 'upstream' | 'worktreeBasePath'>>
|
||||
>(updates: T): T {
|
||||
const sanitized = { ...updates }
|
||||
if ('badgeColor' in sanitized) {
|
||||
|
|
@ -619,6 +635,15 @@ function sanitizeRepoUpdatesForPersistence<
|
|||
sanitized.repoIcon = repoIcon
|
||||
}
|
||||
}
|
||||
// Why: `null` is a valid "not a fork" marker; only drop malformed shapes.
|
||||
if ('upstream' in sanitized) {
|
||||
const upstream = sanitizeRepoUpstream(sanitized.upstream)
|
||||
if (upstream === undefined) {
|
||||
delete sanitized.upstream
|
||||
} else {
|
||||
sanitized.upstream = upstream
|
||||
}
|
||||
}
|
||||
if ('worktreeBasePath' in sanitized && sanitized.worktreeBasePath !== undefined) {
|
||||
if (typeof sanitized.worktreeBasePath === 'string') {
|
||||
sanitized.worktreeBasePath = sanitized.worktreeBasePath.trim() || undefined
|
||||
|
|
@ -2423,6 +2448,7 @@ export class Store {
|
|||
| 'displayName'
|
||||
| 'badgeColor'
|
||||
| 'repoIcon'
|
||||
| 'upstream'
|
||||
| 'hookSettings'
|
||||
| 'worktreeBaseRef'
|
||||
| 'worktreeBasePath'
|
||||
|
|
@ -2504,8 +2530,9 @@ export class Store {
|
|||
}
|
||||
|
||||
private hydrateRepo(repo: Repo): Repo {
|
||||
const { repoIcon: rawRepoIcon, ...repoWithoutIcon } = repo
|
||||
const { repoIcon: rawRepoIcon, upstream: rawUpstream, ...repoWithoutIcon } = repo
|
||||
const repoIcon = sanitizeRepoIcon(rawRepoIcon)
|
||||
const upstream = sanitizeRepoUpstream(rawUpstream)
|
||||
const gitUsername = isFolderRepo(repo)
|
||||
? ''
|
||||
: (this.gitUsernameCache.get(repo.path) ??
|
||||
|
|
@ -2518,6 +2545,7 @@ export class Store {
|
|||
return {
|
||||
...repoWithoutIcon,
|
||||
...(repoIcon !== undefined ? { repoIcon } : {}),
|
||||
...(upstream !== undefined ? { upstream } : {}),
|
||||
kind: isFolderRepo(repo) ? 'folder' : 'git',
|
||||
gitUsername,
|
||||
hookSettings: {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { join } from 'path'
|
|||
import { tmpdir } from 'os'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { gitExecFileAsync } from './git/runner'
|
||||
import { detectRepoIcon } from './repo-icon-autodetect'
|
||||
import { detectRepoIcon, detectRepoIconAndUpstream } from './repo-icon-autodetect'
|
||||
|
||||
const PNG_1X1_BASE64 =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII='
|
||||
|
|
@ -147,4 +147,34 @@ describe('detectRepoIcon', () => {
|
|||
label: 'stablyai/orca'
|
||||
})
|
||||
})
|
||||
|
||||
it('stores a null upstream marker for git repos without a resolved fork parent', async () => {
|
||||
const repoPath = await makeTempRepoDir()
|
||||
await gitExecFileAsync(['init'], { cwd: repoPath })
|
||||
|
||||
await expect(detectRepoIconAndUpstream({ repoPath, kind: 'git' })).resolves.toEqual({
|
||||
upstream: null
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the resolved fork upstream for both metadata and the GitHub avatar', async () => {
|
||||
const repoPath = await makeTempRepoDir()
|
||||
await gitExecFileAsync(['init'], { cwd: repoPath })
|
||||
await gitExecFileAsync(['remote', 'add', 'origin', 'git@github.com:tmchow/orca.git'], {
|
||||
cwd: repoPath
|
||||
})
|
||||
await gitExecFileAsync(['remote', 'add', 'upstream', 'git@github.com:stablyai/orca.git'], {
|
||||
cwd: repoPath
|
||||
})
|
||||
|
||||
await expect(detectRepoIconAndUpstream({ repoPath, kind: 'git' })).resolves.toEqual({
|
||||
repoIcon: {
|
||||
type: 'image',
|
||||
src: 'https://github.com/stablyai.png?size=64',
|
||||
source: 'github',
|
||||
label: 'stablyai/orca'
|
||||
},
|
||||
upstream: { owner: 'stablyai', repo: 'orca' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { readFile, stat } from 'fs/promises'
|
||||
import type { RepoKind } from '../shared/types'
|
||||
import type { GitHubRepositoryIdentity, RepoKind } from '../shared/types'
|
||||
import {
|
||||
faviconUrlFromWebsite,
|
||||
githubAvatarIcon,
|
||||
MAX_REPO_ICON_UPLOAD_BYTES,
|
||||
type RepoIcon
|
||||
} from '../shared/repo-icon'
|
||||
import { getRepoSlug } from './github/client'
|
||||
import { getRepoSlug, getRepoUpstream } from './github/client'
|
||||
import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch'
|
||||
import type { IFilesystemProvider } from './providers/types'
|
||||
import { iconHrefCandidates } from './repo-icon-href-candidates'
|
||||
|
|
@ -257,18 +258,13 @@ async function detectRemotePackageHomepageIcon(
|
|||
|
||||
async function detectGitHubAvatarIcon(
|
||||
repoPath: string,
|
||||
connectionId?: string | null
|
||||
connectionId?: string | null,
|
||||
upstream?: GitHubRepositoryIdentity | null
|
||||
): Promise<RepoIcon | null> {
|
||||
try {
|
||||
const slug = await getRepoSlug(repoPath, connectionId)
|
||||
return slug
|
||||
? {
|
||||
type: 'image',
|
||||
src: `https://github.com/${encodeURIComponent(slug.owner)}.png?size=64`,
|
||||
source: 'github',
|
||||
label: `${slug.owner}/${slug.repo}`
|
||||
}
|
||||
: null
|
||||
// Why: a fork's origin is the personal copy, so prefer the upstream owner.
|
||||
const slug = upstream ?? (await getRepoSlug(repoPath, connectionId))
|
||||
return slug ? githubAvatarIcon(slug) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
|
@ -277,11 +273,13 @@ async function detectGitHubAvatarIcon(
|
|||
export async function detectRepoIcon({
|
||||
repoPath,
|
||||
kind,
|
||||
connectionId
|
||||
connectionId,
|
||||
upstream
|
||||
}: {
|
||||
repoPath: string
|
||||
kind: RepoKind
|
||||
connectionId?: string | null
|
||||
upstream?: GitHubRepositoryIdentity | null
|
||||
}): Promise<RepoIcon | undefined> {
|
||||
try {
|
||||
const fsProvider = connectionId ? getSshFilesystemProvider(connectionId) : undefined
|
||||
|
|
@ -300,10 +298,33 @@ export async function detectRepoIcon({
|
|||
}
|
||||
|
||||
if (kind === 'git') {
|
||||
return (await detectGitHubAvatarIcon(repoPath, connectionId)) ?? undefined
|
||||
return (await detectGitHubAvatarIcon(repoPath, connectionId, upstream)) ?? undefined
|
||||
}
|
||||
} catch {
|
||||
// Repo creation must not fail because a best-effort icon probe failed.
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a repo's icon and its fork upstream together. The upstream is resolved
|
||||
* once and reused for the avatar so a fork shows the upstream owner's avatar.
|
||||
* Returns a spread-ready slice of `Repo`. For git repos, `upstream: null`
|
||||
* is a resolved "not a fork" marker and prevents repeated best-effort probes.
|
||||
*/
|
||||
export async function detectRepoIconAndUpstream({
|
||||
repoPath,
|
||||
kind,
|
||||
connectionId
|
||||
}: {
|
||||
repoPath: string
|
||||
kind: RepoKind
|
||||
connectionId?: string | null
|
||||
}): Promise<{ repoIcon?: RepoIcon; upstream?: GitHubRepositoryIdentity | null }> {
|
||||
const upstream = kind === 'git' ? await getRepoUpstream(repoPath, connectionId) : null
|
||||
const repoIcon = await detectRepoIcon({ repoPath, kind, connectionId, upstream })
|
||||
return {
|
||||
...(repoIcon ? { repoIcon } : {}),
|
||||
...(kind === 'git' ? { upstream: upstream ?? null } : {})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -175,6 +175,7 @@ import { BrowserError } from '../browser/cdp-bridge'
|
|||
import {
|
||||
getPRForBranch,
|
||||
getRepoSlug,
|
||||
getRepoUpstream,
|
||||
getWorkItem,
|
||||
listIssues as listGitHubIssues,
|
||||
listWorkItems,
|
||||
|
|
@ -442,7 +443,8 @@ import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits'
|
|||
import type { IFilesystemProvider, IPtyProvider } from '../providers/types'
|
||||
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import { getSshGitProvider, requireSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import { detectRepoIcon } from '../repo-icon-autodetect'
|
||||
import { detectRepoIconAndUpstream } from '../repo-icon-autodetect'
|
||||
import { githubAvatarIcon } from '../../shared/repo-icon'
|
||||
import type { ClaudeAccountService } from '../claude-accounts/service'
|
||||
import type { CodexAccountService } from '../codex-accounts/service'
|
||||
import type { RateLimitService } from '../rate-limits/service'
|
||||
|
|
@ -1231,6 +1233,7 @@ export class OrcaRuntimeService {
|
|||
private waitersByHandle = new Map<string, Set<TerminalWaiter>>()
|
||||
private ptyController: RuntimePtyController | null = null
|
||||
private notifier: RuntimeNotifier | null = null
|
||||
private forkBackfillStarted = false
|
||||
private agentBrowserBridge: AgentBrowserBridge | null = null
|
||||
private resolvedWorktreeCache: ResolvedWorktreeCache | null = null
|
||||
private resolvedWorktreeInFlight: ResolvedWorktreeInFlight | null = null
|
||||
|
|
@ -1852,6 +1855,12 @@ export class OrcaRuntimeService {
|
|||
|
||||
setNotifier(notifier: RuntimeNotifier | null): void {
|
||||
this.notifier = notifier
|
||||
// Why: run the one-shot fork-upstream backfill once a renderer is attached,
|
||||
// so existing forks self-correct on launch and the result can be broadcast.
|
||||
if (notifier && !this.forkBackfillStarted) {
|
||||
this.forkBackfillStarted = true
|
||||
void this.backfillForkUpstreams()
|
||||
}
|
||||
}
|
||||
|
||||
setAgentBrowserBridge(bridge: AgentBrowserBridge | null): void {
|
||||
|
|
@ -5557,13 +5566,13 @@ export class OrcaRuntimeService {
|
|||
return existing
|
||||
}
|
||||
|
||||
const repoIcon = await detectRepoIcon({ repoPath: path, kind })
|
||||
const detected = await detectRepoIconAndUpstream({ repoPath: path, kind })
|
||||
const repo: Repo = {
|
||||
id: randomUUID(),
|
||||
path,
|
||||
displayName: getRepoName(path),
|
||||
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
||||
...(repoIcon ? { repoIcon } : {}),
|
||||
...detected,
|
||||
addedAt: Date.now(),
|
||||
kind,
|
||||
...(kind === 'git'
|
||||
|
|
@ -5673,13 +5682,13 @@ export class OrcaRuntimeService {
|
|||
return { repo: raceWinner }
|
||||
}
|
||||
|
||||
const repoIcon = await detectRepoIcon({ repoPath: targetPath, kind: repoKind })
|
||||
const detected = await detectRepoIconAndUpstream({ repoPath: targetPath, kind: repoKind })
|
||||
const repo: Repo = {
|
||||
id: randomUUID(),
|
||||
path: targetPath,
|
||||
displayName: trimmedName,
|
||||
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
||||
...(repoIcon ? { repoIcon } : {}),
|
||||
...detected,
|
||||
addedAt: Date.now(),
|
||||
kind: repoKind,
|
||||
...(repoKind === 'git'
|
||||
|
|
@ -5818,13 +5827,13 @@ export class OrcaRuntimeService {
|
|||
return existing
|
||||
}
|
||||
|
||||
const repoIcon = await detectRepoIcon({ repoPath: clonePath, kind: 'git' })
|
||||
const detected = await detectRepoIconAndUpstream({ repoPath: clonePath, kind: 'git' })
|
||||
const repo: Repo = {
|
||||
id: randomUUID(),
|
||||
path: clonePath,
|
||||
displayName: getRepoName(clonePath),
|
||||
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
||||
...(repoIcon ? { repoIcon } : {}),
|
||||
...detected,
|
||||
addedAt: Date.now(),
|
||||
kind: 'git',
|
||||
externalWorktreeVisibility: 'hide',
|
||||
|
|
@ -5866,6 +5875,7 @@ export class OrcaRuntimeService {
|
|||
| 'displayName'
|
||||
| 'badgeColor'
|
||||
| 'repoIcon'
|
||||
| 'upstream'
|
||||
| 'hookSettings'
|
||||
| 'worktreeBaseRef'
|
||||
| 'worktreeBasePath'
|
||||
|
|
@ -6078,6 +6088,46 @@ export class OrcaRuntimeService {
|
|||
return getRepoSlug(repo.path, repo.connectionId ?? null)
|
||||
}
|
||||
|
||||
async getRepoUpstream(repoSelector: string): Promise<{ owner: string; repo: string } | null> {
|
||||
const repo = await this.resolveRepoSelector(repoSelector)
|
||||
return getRepoUpstream(repo.path, repo.connectionId ?? null)
|
||||
}
|
||||
|
||||
// Why: repos added before fork detection existed have no stored `upstream`, so
|
||||
// their avatar/badge would never self-correct. Resolve it once at startup for
|
||||
// local git repos; SSH repos resolve lazily when their settings open (their
|
||||
// connection may not be up yet). Sequential to respect the gh rate limit;
|
||||
// failures leave `upstream` unset so the next launch retries.
|
||||
private async backfillForkUpstreams(): Promise<void> {
|
||||
try {
|
||||
const store = this.requireStore()
|
||||
let changed = false
|
||||
for (const repo of store.getRepos()) {
|
||||
if (repo.upstream !== undefined || repo.kind === 'folder' || repo.connectionId) {
|
||||
continue
|
||||
}
|
||||
let upstream: { owner: string; repo: string } | null
|
||||
try {
|
||||
upstream = await getRepoUpstream(repo.path, null)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
const updates: Partial<Repo> = { upstream: upstream ?? null }
|
||||
// Only migrate the auto-detected origin avatar; never touch a chosen icon.
|
||||
if (upstream && repo.repoIcon?.type === 'image' && repo.repoIcon.source === 'github') {
|
||||
updates.repoIcon = githubAvatarIcon(upstream)
|
||||
}
|
||||
store.updateRepo(repo.id, updates)
|
||||
changed = true
|
||||
}
|
||||
if (changed) {
|
||||
this.notifier?.reposChanged()
|
||||
}
|
||||
} catch {
|
||||
// Best-effort startup backfill; never disrupt launch.
|
||||
}
|
||||
}
|
||||
|
||||
async listRepoWorkItems(
|
||||
repoSelector: string,
|
||||
limit?: number,
|
||||
|
|
|
|||
|
|
@ -285,6 +285,11 @@ export const GITHUB_METHODS: RpcMethod[] = [
|
|||
params: RepoSelector,
|
||||
handler: async (params, { runtime }) => runtime.getRepoSlug(params.repo)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.repoUpstream',
|
||||
params: RepoSelector,
|
||||
handler: async (params, { runtime }) => runtime.getRepoUpstream(params.repo)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.rateLimit',
|
||||
params: RateLimit,
|
||||
|
|
|
|||
|
|
@ -226,6 +226,33 @@ describe('repo RPC methods', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('persists resolved GitHub upstream metadata updates', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
updateRepo: vi.fn().mockResolvedValue({
|
||||
id: 'repo-1',
|
||||
path: '/srv/repo',
|
||||
upstream: { owner: 'stablyai', repo: 'orca' }
|
||||
})
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('repo.update', {
|
||||
repo: 'repo-1',
|
||||
updates: { upstream: { owner: 'stablyai', repo: 'orca' } }
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.updateRepo).toHaveBeenCalledWith('repo-1', {
|
||||
upstream: { owner: 'stablyai', repo: 'orca' }
|
||||
})
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: { repo: { id: 'repo-1', upstream: { owner: 'stablyai', repo: 'orca' } } }
|
||||
})
|
||||
})
|
||||
|
||||
it('routes project group mutations to the runtime server', async () => {
|
||||
const group = {
|
||||
id: 'group-1',
|
||||
|
|
|
|||
|
|
@ -44,6 +44,14 @@ const RepoBadgeColor = z
|
|||
value === undefined ? undefined : (normalizeRepoBadgeColor(value) ?? undefined)
|
||||
)
|
||||
|
||||
const RepoUpstream = z
|
||||
.object({
|
||||
owner: z.string().min(1),
|
||||
repo: z.string().min(1)
|
||||
})
|
||||
.nullable()
|
||||
.optional()
|
||||
|
||||
const RepoUpdate = RepoSelector.extend({
|
||||
updates: z.object({
|
||||
displayName: OptionalString,
|
||||
|
|
@ -52,6 +60,7 @@ const RepoUpdate = RepoSelector.extend({
|
|||
.unknown()
|
||||
.transform((value) => sanitizeRepoIcon(value))
|
||||
.optional(),
|
||||
upstream: RepoUpstream,
|
||||
hookSettings: z.unknown().optional(),
|
||||
worktreeBaseRef: OptionalString,
|
||||
worktreeBasePath: OptionalString,
|
||||
|
|
|
|||
|
|
@ -698,6 +698,7 @@ export type PreloadApi = {
|
|||
| 'displayName'
|
||||
| 'badgeColor'
|
||||
| 'repoIcon'
|
||||
| 'upstream'
|
||||
| 'hookSettings'
|
||||
| 'worktreeBaseRef'
|
||||
| 'worktreeBasePath'
|
||||
|
|
@ -953,6 +954,10 @@ export type PreloadApi = {
|
|||
repoPath: string
|
||||
repoId?: string
|
||||
}) => Promise<{ owner: string; repo: string } | null>
|
||||
repoUpstream: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
}) => Promise<{ owner: string; repo: string } | null>
|
||||
prForBranch: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
|
|
|
|||
|
|
@ -822,6 +822,9 @@ const api = {
|
|||
repoSlug: (args: { repoPath: string; repoId?: string }): Promise<unknown> =>
|
||||
ipcRenderer.invoke('gh:repoSlug', args),
|
||||
|
||||
repoUpstream: (args: { repoPath: string; repoId?: string }): Promise<unknown> =>
|
||||
ipcRenderer.invoke('gh:repoUpstream', args),
|
||||
|
||||
prForBranch: (args: {
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
import React from 'react'
|
||||
import { GitFork } from 'lucide-react'
|
||||
import type { GitHubRepositoryIdentity } from '../../../../shared/types'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Small muted glyph marking a repo as a fork, with a "Fork of owner/repo"
|
||||
* tooltip. Renders nothing when the repo has no resolved upstream.
|
||||
*/
|
||||
export function RepoForkIndicator({
|
||||
upstream,
|
||||
className
|
||||
}: {
|
||||
upstream: GitHubRepositoryIdentity | null | undefined
|
||||
className?: string
|
||||
}): React.JSX.Element | null {
|
||||
if (!upstream) {
|
||||
return null
|
||||
}
|
||||
const label = `Fork of ${upstream.owner}/${upstream.repo}`
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn('inline-flex shrink-0 items-center text-muted-foreground', className)}
|
||||
aria-label={label}
|
||||
>
|
||||
<GitFork className="size-3" aria-hidden="true" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,8 +1,12 @@
|
|||
import { useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Github, Image, Link2, RotateCcw } from 'lucide-react'
|
||||
import type { Repo } from '../../../../shared/types'
|
||||
import { faviconUrlFromWebsite, type RepoIcon } from '../../../../shared/repo-icon'
|
||||
import type { GitHubRepositoryIdentity, Repo } from '../../../../shared/types'
|
||||
import {
|
||||
faviconUrlFromWebsite,
|
||||
githubAvatarIcon,
|
||||
type RepoIcon
|
||||
} from '../../../../shared/repo-icon'
|
||||
import { DEFAULT_REPO_BADGE_COLOR, REPO_COLORS } from '../../../../shared/constants'
|
||||
import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color'
|
||||
import { Button } from '../ui/button'
|
||||
|
|
@ -28,17 +32,21 @@ export function RepositoryIconPicker({
|
|||
}): React.JSX.Element {
|
||||
const [website, setWebsite] = useState('')
|
||||
const [loadingGitHub, setLoadingGitHub] = useState(false)
|
||||
const [resetting, setResetting] = useState(false)
|
||||
const mountedRef = useMountedRef()
|
||||
const activeRuntimeEnvironmentId = useAppStore(
|
||||
(state) => state.settings?.activeRuntimeEnvironmentId ?? null
|
||||
)
|
||||
const selectedLucideName =
|
||||
repo.repoIcon?.type === 'lucide' ? repo.repoIcon.name : repo.repoIcon == null ? 'Folder' : null
|
||||
// Why: only highlight a lucide tile when one is explicitly chosen; a null icon
|
||||
// means "default avatar", not the Folder fallback the glyph happens to render.
|
||||
const selectedLucideName = repo.repoIcon?.type === 'lucide' ? repo.repoIcon.name : null
|
||||
const selectedEmoji = repo.repoIcon?.type === 'emoji' ? repo.repoIcon.emoji : ''
|
||||
const selectedBadgeColor = normalizeRepoBadgeColor(repo.badgeColor) ?? DEFAULT_REPO_BADGE_COLOR
|
||||
const isPresetBadgeColor = REPO_COLORS.some((color) => color === selectedBadgeColor)
|
||||
// Why: the GitHub avatar is the default icon, so open on the Avatar tab unless
|
||||
// the repo already uses an explicit emoji or lucide icon.
|
||||
const initialTab =
|
||||
repo.repoIcon?.type === 'image' ? 'image' : repo.repoIcon?.type === 'emoji' ? 'emoji' : 'icon'
|
||||
repo.repoIcon?.type === 'emoji' ? 'emoji' : repo.repoIcon?.type === 'lucide' ? 'icon' : 'avatar'
|
||||
const runtimeTarget = useMemo(
|
||||
() => getActiveRuntimeTarget({ activeRuntimeEnvironmentId }),
|
||||
[activeRuntimeEnvironmentId]
|
||||
|
|
@ -46,15 +54,21 @@ export function RepositoryIconPicker({
|
|||
|
||||
const currentIconLabel = useMemo(() => {
|
||||
if (repo.repoIcon?.type === 'image') {
|
||||
if (repo.repoIcon.source === 'github') {
|
||||
return 'GitHub avatar'
|
||||
}
|
||||
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`
|
||||
if (repo.repoIcon?.type === 'lucide') {
|
||||
const label =
|
||||
REPO_LUCIDE_ICON_OPTIONS.find((option) => option.name === selectedLucideName)?.label ??
|
||||
'Folder'
|
||||
return `${label} icon with repo color`
|
||||
}
|
||||
return 'Default'
|
||||
}, [repo.repoIcon, selectedLucideName])
|
||||
|
||||
const setIcon = (repoIcon: RepoIcon | null) => updateRepo(repo.id, { repoIcon })
|
||||
|
|
@ -86,35 +100,52 @@ export function RepositoryIconPicker({
|
|||
setIcon({ type: 'image', src, source: 'favicon', label: 'Website favicon' })
|
||||
}
|
||||
|
||||
// Why: SSH runtime repos only exist remotely, so resolve their git remotes
|
||||
// through the active runtime instead of the local Electron main process.
|
||||
const resolveUpstreamLive = useCallback(async (): Promise<GitHubRepositoryIdentity | null> => {
|
||||
return runtimeTarget.kind === 'environment'
|
||||
? await callRuntimeRpc<GitHubRepositoryIdentity | null>(
|
||||
runtimeTarget,
|
||||
'github.repoUpstream',
|
||||
{ repo: repo.id },
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
: await window.api.gh.repoUpstream({ repoPath: repo.path, repoId: repo.id })
|
||||
}, [runtimeTarget, repo.id, repo.path])
|
||||
|
||||
const resolveGitHubAvatarIcon = async (): Promise<RepoIcon | null> => {
|
||||
// Why: a fork's default avatar is the upstream owner, not the personal fork
|
||||
// that `origin` points at. Use the stored value when known, else resolve live
|
||||
// (covers repos added before fork detection existed).
|
||||
const upstream =
|
||||
repo.upstream !== undefined ? repo.upstream : await resolveUpstreamLive().catch(() => null)
|
||||
if (upstream) {
|
||||
return githubAvatarIcon(upstream)
|
||||
}
|
||||
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 })
|
||||
return slug ? githubAvatarIcon(slug) : null
|
||||
}
|
||||
|
||||
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) {
|
||||
if (mountedRef.current) {
|
||||
toast.error('No GitHub remote found for this repo.')
|
||||
}
|
||||
return
|
||||
}
|
||||
const icon = await resolveGitHubAvatarIcon()
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
setIcon({
|
||||
type: 'image',
|
||||
src: `https://github.com/${encodeURIComponent(slug.owner)}.png?size=64`,
|
||||
source: 'github',
|
||||
label: `${slug.owner}/${slug.repo}`
|
||||
})
|
||||
if (!icon) {
|
||||
toast.error('No GitHub remote found for this repo.')
|
||||
return
|
||||
}
|
||||
setIcon(icon)
|
||||
} catch {
|
||||
if (mountedRef.current) {
|
||||
toast.error('Failed to resolve the GitHub repo.')
|
||||
|
|
@ -126,6 +157,58 @@ export function RepositoryIconPicker({
|
|||
}
|
||||
}
|
||||
|
||||
// Why: the GitHub avatar is the default repo icon, so Reset restores it when a
|
||||
// GitHub remote exists and otherwise clears to the Folder fallback (null).
|
||||
const handleResetToDefault = async () => {
|
||||
setResetting(true)
|
||||
try {
|
||||
const icon = await resolveGitHubAvatarIcon().catch(() => null)
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
setIcon(icon)
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setResetting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: repos added before fork detection existed have no stored upstream.
|
||||
// Resolve it once when their settings open so existing forks pick up the
|
||||
// upstream avatar and fork badge without a manual reset.
|
||||
const upstreamBackfilledRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
// Why: the ref blocks a re-fire during the in-flight window, before the
|
||||
// stored upstream propagates back through props on the next render.
|
||||
if (repo.upstream !== undefined || upstreamBackfilledRef.current === repo.id) {
|
||||
return
|
||||
}
|
||||
upstreamBackfilledRef.current = repo.id
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
let upstream: GitHubRepositoryIdentity | null
|
||||
try {
|
||||
upstream = await resolveUpstreamLive()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (cancelled || !mountedRef.current) {
|
||||
return
|
||||
}
|
||||
const updates: Partial<Repo> = { upstream: upstream ?? null }
|
||||
// Only migrate the auto-detected origin avatar; never override an icon the
|
||||
// user explicitly chose.
|
||||
if (upstream && repo.repoIcon?.type === 'image' && repo.repoIcon.source === 'github') {
|
||||
updates.repoIcon = githubAvatarIcon(upstream)
|
||||
}
|
||||
updateRepo(repo.id, updates)
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [repo.id, repo.upstream, repo.repoIcon, resolveUpstreamLive, updateRepo, mountedRef])
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
|
|
@ -144,7 +227,8 @@ export function RepositoryIconPicker({
|
|||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={() => setIcon(null)}
|
||||
disabled={resetting}
|
||||
onClick={() => void handleResetToDefault()}
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
Reset
|
||||
|
|
@ -188,17 +272,63 @@ export function RepositoryIconPicker({
|
|||
|
||||
<Tabs defaultValue={initialTab} className="gap-3">
|
||||
<TabsList variant="line" className="h-8">
|
||||
<TabsTrigger value="avatar" className="h-7 text-xs">
|
||||
Avatar
|
||||
</TabsTrigger>
|
||||
<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="avatar" className="space-y-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
className="w-full gap-2"
|
||||
disabled={loadingGitHub}
|
||||
onClick={() => void handleUseGitHubAvatar()}
|
||||
>
|
||||
<Github className="size-3.5" />
|
||||
Use GitHub Avatar
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Used by default — GitHub always provides one, even when the owner hasn't set a
|
||||
custom image.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={handleUploadImage}
|
||||
>
|
||||
<Image className="size-3.5" />
|
||||
Upload PNG
|
||||
</Button>
|
||||
<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 uploads must be 256KB or smaller.</p>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="icon" className="space-y-3">
|
||||
<div className="grid grid-cols-10 gap-1.5">
|
||||
{REPO_LUCIDE_ICON_OPTIONS.map((option) => (
|
||||
|
|
@ -238,51 +368,6 @@ export function RepositoryIconPicker({
|
|||
</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
|
||||
</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 uploads must be 256KB or smaller.</p>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -866,7 +866,8 @@ function Settings(): React.JSX.Element {
|
|||
...section,
|
||||
badgeColor: repo?.badgeColor,
|
||||
isRemote: !!repo?.connectionId,
|
||||
repoIcon: repo?.repoIcon
|
||||
repoIcon: repo?.repoIcon,
|
||||
upstream: repo?.upstream
|
||||
}
|
||||
})
|
||||
const isSectionMounted = (sectionId: string): boolean => neededSectionIds.has(sectionId)
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ import { ArrowLeft, Search, Server } from 'lucide-react'
|
|||
import logo from '../../../../../resources/logo.svg'
|
||||
import type { RepoIcon } from '../../../../shared/repo-icon'
|
||||
import type { SettingsNavIcon, SettingsNavInstallStatus } from '@/lib/settings-navigation-types'
|
||||
import type { GitHubRepositoryIdentity } from '../../../../shared/types'
|
||||
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { RepoIconGlyph } from '../repo/repo-icon'
|
||||
import { RepoForkIndicator } from '../repo/repo-fork-indicator'
|
||||
import { Button } from '../ui/button'
|
||||
import { Input } from '../ui/input'
|
||||
import { SetupGuideProgressRing } from '../setup-guide/SetupGuideProgressRing'
|
||||
|
|
@ -30,6 +32,7 @@ type RepoNavSection = NavSection & {
|
|||
badgeColor?: string
|
||||
isRemote?: boolean
|
||||
repoIcon?: RepoIcon | null
|
||||
upstream?: GitHubRepositoryIdentity | null
|
||||
}
|
||||
|
||||
type SettingsSidebarProps = {
|
||||
|
|
@ -273,6 +276,7 @@ export function SettingsSidebar({
|
|||
iconClassName="size-3.5"
|
||||
/>
|
||||
<span className="truncate">{section.title}</span>
|
||||
<RepoForkIndicator upstream={section.upstream} />
|
||||
{section.isRemote && (
|
||||
<span className="ml-auto inline-flex shrink-0 items-center gap-1 text-[10px] text-muted-foreground">
|
||||
<Server className="size-3" />
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ export function getRepositoryPaneSearchEntries(repo: Repo): SettingsSearchEntry[
|
|||
'color',
|
||||
'hex',
|
||||
'badge',
|
||||
'avatar',
|
||||
'github',
|
||||
'emoji',
|
||||
'favicon'
|
||||
]
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ import {
|
|||
isLegacyRepoForExternalWorktreeVisibility
|
||||
} from '../../../../shared/worktree-ownership'
|
||||
import { RepoIconGlyph } from '@/components/repo/repo-icon'
|
||||
import { RepoForkIndicator } from '@/components/repo/repo-fork-indicator'
|
||||
import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
|
||||
import ImportedWorktreesVisibilityLine from './ImportedWorktreesVisibilityLine'
|
||||
import {
|
||||
|
|
@ -2823,6 +2824,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
<div className="min-w-0 truncate text-[13px] font-semibold leading-none">
|
||||
{row.label}
|
||||
</div>
|
||||
<RepoForkIndicator upstream={row.repo?.upstream} />
|
||||
<SectionMetricsBadge
|
||||
count={row.count}
|
||||
summary={sectionActivity}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ type RepoUpdate = Partial<
|
|||
| 'displayName'
|
||||
| 'badgeColor'
|
||||
| 'repoIcon'
|
||||
| 'upstream'
|
||||
| 'hookSettings'
|
||||
| 'worktreeBaseRef'
|
||||
| 'worktreeBasePath'
|
||||
|
|
|
|||
|
|
@ -162,6 +162,7 @@ type WebGitHubApi = NonNullable<PreloadApi['gh']>
|
|||
type WebGitHubResult<K extends keyof WebGitHubApi> = Awaited<ReturnType<WebGitHubApi[K]>>
|
||||
type WebGitHubRouteKey =
|
||||
| 'repoSlug'
|
||||
| 'repoUpstream'
|
||||
| 'prForBranch'
|
||||
| 'issue'
|
||||
| 'workItem'
|
||||
|
|
@ -209,6 +210,7 @@ type WebGitHubRouteKey =
|
|||
| 'updateIssueTypeBySlug'
|
||||
type WebGitHubRuntimeMethod =
|
||||
| 'github.repoSlug'
|
||||
| 'github.repoUpstream'
|
||||
| 'github.prForBranch'
|
||||
| 'github.issue'
|
||||
| 'github.workItem'
|
||||
|
|
@ -309,6 +311,7 @@ type WebKeybindingDocument = {
|
|||
|
||||
export const GITHUB_WEB_RPC_METHODS = {
|
||||
repoSlug: 'github.repoSlug',
|
||||
repoUpstream: 'github.repoUpstream',
|
||||
prForBranch: 'github.prForBranch',
|
||||
issue: 'github.issue',
|
||||
workItem: 'github.workItem',
|
||||
|
|
@ -1394,6 +1397,8 @@ function createGitHubApi(): WebGitHubApi {
|
|||
const githubApi = {
|
||||
viewer: () => Promise.resolve(null),
|
||||
repoSlug: (args) => route<WebGitHubResult<'repoSlug'>>(GITHUB_WEB_RPC_METHODS.repoSlug, args),
|
||||
repoUpstream: (args) =>
|
||||
route<WebGitHubResult<'repoUpstream'>>(GITHUB_WEB_RPC_METHODS.repoUpstream, args),
|
||||
prForBranch: (args) =>
|
||||
route<WebGitHubResult<'prForBranch'>>(GITHUB_WEB_RPC_METHODS.prForBranch, args),
|
||||
refreshPRNow: async ({ candidate }) => {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,17 @@ export function faviconUrlFromWebsite(rawUrl: string): string | 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)
|
||||
|
|
|
|||
|
|
@ -83,6 +83,10 @@ export type Repo = {
|
|||
displayName: string
|
||||
badgeColor: string
|
||||
repoIcon?: RepoIcon | null
|
||||
/** Set when the repo is a fork: the upstream/parent owner/repo. Drives the
|
||||
* default avatar (upstream owner, not the personal fork) and the fork
|
||||
* indicator. Absent = not a fork, or fork status not yet resolved. */
|
||||
upstream?: GitHubRepositoryIdentity | null
|
||||
addedAt: number
|
||||
kind?: RepoKind
|
||||
gitUsername?: string
|
||||
|
|
|
|||
Loading…
Reference in New Issue