From abfe9bd3299132882d18f264a4a6faa9b9a63ca8 Mon Sep 17 00:00:00 2001 From: Trevin Chow Date: Tue, 2 Jun 2026 20:55:35 -0700 Subject: [PATCH] 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> --- src/main/github/client.test.ts | 53 ++++ src/main/github/client.ts | 42 +++ src/main/ipc/github.ts | 6 + src/main/ipc/repos.ts | 25 +- src/main/persistence.test.ts | 23 ++ src/main/persistence.ts | 32 ++- src/main/repo-icon-autodetect.test.ts | 32 ++- src/main/repo-icon-autodetect.ts | 49 +++- src/main/runtime/orca-runtime.ts | 64 ++++- src/main/runtime/rpc/methods/github.ts | 5 + src/main/runtime/rpc/methods/repo.test.ts | 27 ++ src/main/runtime/rpc/methods/repo.ts | 9 + src/preload/api-types.ts | 5 + src/preload/index.ts | 3 + .../components/repo/repo-fork-indicator.tsx | 37 +++ .../settings/RepositoryIconPicker.tsx | 249 ++++++++++++------ .../src/components/settings/Settings.tsx | 3 +- .../components/settings/SettingsSidebar.tsx | 4 + .../components/settings/repository-search.ts | 2 + .../src/components/sidebar/WorktreeList.tsx | 2 + src/renderer/src/store/slices/repos.ts | 1 + src/renderer/src/web/web-preload-api.ts | 5 + src/shared/repo-icon.ts | 11 + src/shared/types.ts | 4 + 24 files changed, 577 insertions(+), 116 deletions(-) create mode 100644 src/renderer/src/components/repo/repo-fork-indicator.tsx diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index c4cb3b55d..84eb40c4c 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -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: [ diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 73feeb4cc..9254157e0 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -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 { + 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() diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index bce2e4544..e6810e1d1 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -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', ( diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index a0c1733a2..83a711689 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -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', diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 7e069f605..8574c871e 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -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() diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 50edca981..a5193095f 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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> + T extends Partial> >(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: { diff --git a/src/main/repo-icon-autodetect.test.ts b/src/main/repo-icon-autodetect.test.ts index 7d0aa0cec..ea2ea2ea8 100644 --- a/src/main/repo-icon-autodetect.test.ts +++ b/src/main/repo-icon-autodetect.test.ts @@ -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' } + }) + }) }) diff --git a/src/main/repo-icon-autodetect.ts b/src/main/repo-icon-autodetect.ts index 6101c3a79..f9f0b52a6 100644 --- a/src/main/repo-icon-autodetect.ts +++ b/src/main/repo-icon-autodetect.ts @@ -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 { 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 { 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 } : {}) + } +} diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 26b4e9650..1da5e7e32 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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>() 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 { + 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 = { 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, diff --git a/src/main/runtime/rpc/methods/github.ts b/src/main/runtime/rpc/methods/github.ts index 4c6db9b58..b9a2b5dca 100644 --- a/src/main/runtime/rpc/methods/github.ts +++ b/src/main/runtime/rpc/methods/github.ts @@ -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, diff --git a/src/main/runtime/rpc/methods/repo.test.ts b/src/main/runtime/rpc/methods/repo.test.ts index c18bc1c53..319d8b0ff 100644 --- a/src/main/runtime/rpc/methods/repo.test.ts +++ b/src/main/runtime/rpc/methods/repo.test.ts @@ -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', diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts index 83a2b6061..df95eb4df 100644 --- a/src/main/runtime/rpc/methods/repo.ts +++ b/src/main/runtime/rpc/methods/repo.ts @@ -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, diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 61943ce93..198fd9c4f 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 diff --git a/src/preload/index.ts b/src/preload/index.ts index e48b686ce..0f9fb0a4b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -822,6 +822,9 @@ const api = { repoSlug: (args: { repoPath: string; repoId?: string }): Promise => ipcRenderer.invoke('gh:repoSlug', args), + repoUpstream: (args: { repoPath: string; repoId?: string }): Promise => + ipcRenderer.invoke('gh:repoUpstream', args), + prForBranch: (args: { repoPath: string repoId?: string diff --git a/src/renderer/src/components/repo/repo-fork-indicator.tsx b/src/renderer/src/components/repo/repo-fork-indicator.tsx new file mode 100644 index 000000000..896a52177 --- /dev/null +++ b/src/renderer/src/components/repo/repo-fork-indicator.tsx @@ -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 ( + + + + + + + {label} + + + ) +} diff --git a/src/renderer/src/components/settings/RepositoryIconPicker.tsx b/src/renderer/src/components/settings/RepositoryIconPicker.tsx index 31f059296..92a05619c 100644 --- a/src/renderer/src/components/settings/RepositoryIconPicker.tsx +++ b/src/renderer/src/components/settings/RepositoryIconPicker.tsx @@ -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 => { + return runtimeTarget.kind === 'environment' + ? await callRuntimeRpc( + 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 => { + // 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(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 = { 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 (
@@ -144,7 +227,8 @@ export function RepositoryIconPicker({ variant="outline" size="sm" className="gap-2" - onClick={() => setIcon(null)} + disabled={resetting} + onClick={() => void handleResetToDefault()} > Reset @@ -188,17 +272,63 @@ export function RepositoryIconPicker({ + + Avatar + Icon Emoji - - Image - + + +

+ Used by default — GitHub always provides one, even when the owner hasn't set a + custom image. +

+ +
+ setWebsite(event.target.value)} + placeholder="example.com" + className="h-9 text-sm" + /> + +
+

PNG uploads must be 256KB or smaller.

+
+
{REPO_LUCIDE_ICON_OPTIONS.map((option) => ( @@ -238,51 +368,6 @@ export function RepositoryIconPicker({ ))} - - -
- - -
-
- setWebsite(event.target.value)} - placeholder="example.com" - className="h-9 text-sm" - /> - -
-

PNG uploads must be 256KB or smaller.

-
) diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 4ae3c7e2a..3b0b839a9 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -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) diff --git a/src/renderer/src/components/settings/SettingsSidebar.tsx b/src/renderer/src/components/settings/SettingsSidebar.tsx index f572ffc49..b70868672 100644 --- a/src/renderer/src/components/settings/SettingsSidebar.tsx +++ b/src/renderer/src/components/settings/SettingsSidebar.tsx @@ -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" /> {section.title} + {section.isRemote && ( diff --git a/src/renderer/src/components/settings/repository-search.ts b/src/renderer/src/components/settings/repository-search.ts index 3d13edd7b..444b55594 100644 --- a/src/renderer/src/components/settings/repository-search.ts +++ b/src/renderer/src/components/settings/repository-search.ts @@ -20,6 +20,8 @@ export function getRepositoryPaneSearchEntries(repo: Repo): SettingsSearchEntry[ 'color', 'hex', 'badge', + 'avatar', + 'github', 'emoji', 'favicon' ] diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 9e6abeadd..e597201a0 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -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
{row.label}
+ type WebGitHubResult = Awaited> 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>(GITHUB_WEB_RPC_METHODS.repoSlug, args), + repoUpstream: (args) => + route>(GITHUB_WEB_RPC_METHODS.repoUpstream, args), prForBranch: (args) => route>(GITHUB_WEB_RPC_METHODS.prForBranch, args), refreshPRNow: async ({ candidate }) => { diff --git a/src/shared/repo-icon.ts b/src/shared/repo-icon.ts index 987de02f7..01ac83c13 100644 --- a/src/shared/repo-icon.ts +++ b/src/shared/repo-icon.ts @@ -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) diff --git a/src/shared/types.ts b/src/shared/types.ts index 3496f3c9b..a47571b48 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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