diff --git a/package.json b/package.json index 8a1c293..f707155 100644 --- a/package.json +++ b/package.json @@ -518,6 +518,14 @@ "default": true, "markdownDescription": "Automatically link references based on the `origin` remote. `!123` (GitLab merge requests) is linked for **any** host, including self-hosted GitLab. `#123` (issues) is linked for **github.com** and **gitlab.com** only — on a self-hosted host the issue path can't be determined, so add a `gitGraphPlus.commitMessageLinks` rule for those." }, + "gitGraphPlus.avatarOverrides": { + "type": "object", + "default": {}, + "additionalProperties": { + "type": "string" + }, + "markdownDescription": "Map commit-author emails to explicit avatar image URLs. Used when an email has neither a Gravatar account nor a GitHub `noreply` address, so the author still shows a real avatar. Keys are matched case-insensitively. Example: `{ \"you@example.com\": \"https://avatars.githubusercontent.com/u/12345\" }`. Entries whose value is not an `https://` URL are ignored." + }, "gitGraphPlus.defaults.push.force": { "type": "string", "enum": ["none", "with-lease", "force"], "default": "none", "markdownDescription": "⚠️ `with-lease`/`force` pre-checks a force push, which can overwrite remote history." }, "gitGraphPlus.defaults.push.setUpstream": { "type": "boolean", "default": true, "markdownDescription": "Set the upstream tracking reference with `-u` when pushing." }, "gitGraphPlus.defaults.push.allTags": { "type": "boolean", "default": false, "markdownDescription": "Also push all tags (`--tags`)." }, diff --git a/src/panels/MainPanel.ts b/src/panels/MainPanel.ts index 5869905..6dc84ec 100644 --- a/src/panels/MainPanel.ts +++ b/src/panels/MainPanel.ts @@ -5,7 +5,7 @@ import { GitService, GitError } from '../git/git-service'; import { formatGitError, isAuthFailure, transportFromRemoteUrl } from '../git/git-error-formatter'; import { splitUpstreamRef } from '../git/git-parser'; import { samePath } from '../utils/path'; -import { readTimeoutMs, readInitialCommitCount, readLoadMoreCommitCount, readInteractiveRebaseMode } from '../utils/config'; +import { readTimeoutMs, readInitialCommitCount, readLoadMoreCommitCount, readInteractiveRebaseMode, readAvatarOverrides } from '../utils/config'; import { buildClassicRebaseCommand } from '../git/classic-rebase'; import { buildFullGraph } from '../git/git-graph-builder'; import { compileBranchColorRules, makeBranchColorResolver } from '../git/branch-color-resolver'; @@ -90,7 +90,9 @@ export class MainPanel { private static getAvatarCache(): AvatarCache { if (!this.avatarCache) { - this.avatarCache = new AvatarCache(this.avatarCacheDir ?? null); + this.avatarCache = new AvatarCache(this.avatarCacheDir ?? null, undefined, { + avatarOverrides: readAvatarOverrides(), + }); } return this.avatarCache; } diff --git a/src/services/__tests__/avatar-cache.test.ts b/src/services/__tests__/avatar-cache.test.ts index 353152f..16e5268 100644 --- a/src/services/__tests__/avatar-cache.test.ts +++ b/src/services/__tests__/avatar-cache.test.ts @@ -39,6 +39,85 @@ describe('AvatarCache', () => { expect(calls[0]).toContain('s=32'); }); + it('resolves a GitHub + noreply email to avatars.githubusercontent.com', async () => { + const { fetcher, calls } = makeFetcher(); + const cache = new AvatarCache(null, fetcher); + + await cache.get('73097985+the0807@users.noreply.github.com', 32); + + expect(calls).toHaveLength(1); + expect(calls[0]).toBe('https://avatars.githubusercontent.com/u/73097985?s=32'); + }); + + it('resolves a bare GitHub noreply email to avatars.githubusercontent.com', async () => { + const { fetcher, calls } = makeFetcher(); + const cache = new AvatarCache(null, fetcher); + + await cache.get('the0807@users.noreply.github.com', 48); + + expect(calls).toHaveLength(1); + expect(calls[0]).toBe('https://avatars.githubusercontent.com/the0807?s=48'); + }); + + it('resolves noreply emails case-insensitively after normalization', async () => { + const { fetcher, calls } = makeFetcher(); + const cache = new AvatarCache(null, fetcher); + + await cache.get(' 73097985+The0807@Users.Noreply.GitHub.com ', 32); + + expect(calls).toHaveLength(1); + expect(calls[0]).toBe('https://avatars.githubusercontent.com/u/73097985?s=32'); + }); + + it('falls back to Gravatar for a non-noreply email', async () => { + const { fetcher, calls } = makeFetcher(); + const cache = new AvatarCache(null, fetcher); + + await cache.get('alice@example.com', 32); + + expect(calls).toHaveLength(1); + expect(calls[0]).toContain('https://www.gravatar.com/avatar/'); + expect(calls[0]).toContain('s=32'); + }); + + it('uses an explicit override URL for a matching email', async () => { + const { fetcher, calls } = makeFetcher(); + const cache = new AvatarCache(null, fetcher, { + avatarOverrides: { 'alice@example.com': 'https://example.com/alice.png' }, + }); + + await cache.get('alice@example.com', 32); + + expect(calls).toHaveLength(1); + expect(calls[0]).toBe('https://example.com/alice.png'); + }); + + it('matches override keys case-insensitively after email normalization', async () => { + const { fetcher, calls } = makeFetcher(); + const cache = new AvatarCache(null, fetcher, { + avatarOverrides: { 'alice@example.com': 'https://example.com/alice.png' }, + }); + + await cache.get(' Alice@Example.com ', 32); + + expect(calls).toHaveLength(1); + expect(calls[0]).toBe('https://example.com/alice.png'); + }); + + it('lets an override take priority over a GitHub noreply email', async () => { + const { fetcher, calls } = makeFetcher(); + const cache = new AvatarCache(null, fetcher, { + avatarOverrides: { + '73097985+the0807@users.noreply.github.com': 'https://example.com/custom.png', + }, + }); + + await cache.get('73097985+the0807@users.noreply.github.com', 32); + + expect(calls).toHaveLength(1); + expect(calls[0]).toBe('https://example.com/custom.png'); + }); + it('serves repeat requests from memory without re-fetching', async () => { const { fetcher, calls } = makeFetcher(); const cache = new AvatarCache(null, fetcher); diff --git a/src/services/avatar-cache.ts b/src/services/avatar-cache.ts index d9238b6..70bf5bd 100644 --- a/src/services/avatar-cache.ts +++ b/src/services/avatar-cache.ts @@ -27,23 +27,52 @@ function normalizeEmail(email: string): string { return email.trim().toLowerCase(); } -/** Caches Gravatar avatars in the extension host (memory + disk) and serves - * them to the webview as base64 data URIs. This keeps the webview renderer - * from opening a fresh connection to gravatar.com on every render/scroll/ - * window — the root cause of the socket exhaustion in issue #38. */ +/** Matches GitHub's private "noreply" emails: either `+` or a bare + * ``, both at users.noreply.github.com. Authors committing through + * GitHub's "keep my email private" option get exactly these addresses. */ +const GITHUB_NOREPLY_RE = /^(?:(\d+)\+)?([^@]+)@users\.noreply\.github\.com$/i; + +/** Returns the avatar URL for a normalized commit-author email. An explicit + * override wins first; then GitHub noreply emails resolve to + * avatars.githubusercontent.com (where the real avatar lives); everything + * else falls back to Gravatar, whose `d=retro` parameter generates a + * placeholder for emails without a Gravatar account. */ +function avatarUrlForEmail(normEmail: string, size: number, overrides: Record): string { + const override = overrides[normEmail]; + if (override) return override; + + const m = GITHUB_NOREPLY_RE.exec(normEmail); + if (m) { + const [, id, login] = m; + return id !== undefined + ? `https://avatars.githubusercontent.com/u/${id}?s=${size}` + : `https://avatars.githubusercontent.com/${login}?s=${size}`; + } + const hash = md5(normEmail); + return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=retro`; +} + +/** Caches commit-author avatars in the extension host (memory + disk) and + * serves them to the webview as base64 data URIs. GitHub "noreply" emails + * resolve to avatars.githubusercontent.com; everything else uses Gravatar. + * Caching keeps the webview renderer from opening a fresh connection on + * every render/scroll/window — the root cause of the socket exhaustion in + * issue #38. */ export class AvatarCache { private memory = new Map(); // key -> data URI private inflight = new Map>(); private maxDiskEntries: number; private ttlMs: number; + private overrides: Record; constructor( private cacheDir: string | null = null, private fetcher: AvatarFetcher = defaultFetcher, - opts?: { maxDiskEntries?: number; ttlMs?: number }, + opts?: { maxDiskEntries?: number; ttlMs?: number; avatarOverrides?: Record }, ) { this.maxDiskEntries = opts?.maxDiskEntries ?? DEFAULT_MAX_DISK_ENTRIES; this.ttlMs = opts?.ttlMs ?? DEFAULT_TTL_MS; + this.overrides = opts?.avatarOverrides ?? {}; } /** Returns a base64 data URI for the avatar, or null if it cannot be loaded. */ @@ -88,7 +117,7 @@ export class AvatarCache { } } - const url = `https://www.gravatar.com/avatar/${hash}?s=${size}&d=retro`; + const url = avatarUrlForEmail(normEmail, size, this.overrides); const res = await this.fetcher(url); if (!res) { // Refresh failed (e.g. offline). Fall back to the stale copy if we have diff --git a/src/utils/__tests__/config.test.ts b/src/utils/__tests__/config.test.ts index 63667d3..f4d7b0b 100644 --- a/src/utils/__tests__/config.test.ts +++ b/src/utils/__tests__/config.test.ts @@ -13,7 +13,7 @@ vi.mock('vscode', () => ({ }, })); -import { readTimeoutMs, readInitialCommitCount, readLoadMoreCommitCount } from '../config'; +import { readTimeoutMs, readInitialCommitCount, readLoadMoreCommitCount, readAvatarOverrides } from '../config'; describe('readTimeoutMs', () => { // Back-compat alias so the existing timeout cases below read naturally. @@ -101,3 +101,37 @@ describe('readLoadMoreCommitCount', () => { expect(readLoadMoreCommitCount()).toBe(50); }); }); + +describe('readAvatarOverrides', () => { + beforeEach(() => { h.values = {}; }); + + it('returns an empty map when the setting is unset', () => { + expect(readAvatarOverrides()).toEqual({}); + }); + + it('returns https URL entries with normalized keys', () => { + h.values.avatarOverrides = { + ' Alice@Example.com ': ' https://example.com/a.png ', + 'bob@example.com': 'https://example.com/b.png', + }; + expect(readAvatarOverrides()).toEqual({ + 'alice@example.com': 'https://example.com/a.png', + 'bob@example.com': 'https://example.com/b.png', + }); + }); + + it('ignores non-string, empty, and non-https values', () => { + h.values.avatarOverrides = { + 'a@example.com': 'http://example.com/a.png', // wrong protocol + 'b@example.com': '', + 'c@example.com': 42, + 'd@example.com': null, + }; + expect(readAvatarOverrides()).toEqual({}); + }); + + it('ignores a non-object setting', () => { + h.values.avatarOverrides = 'not-an-object'; + expect(readAvatarOverrides()).toEqual({}); + }); +}); diff --git a/src/utils/config.ts b/src/utils/config.ts index fa8fabe..ce22b80 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -47,3 +47,25 @@ export function readInteractiveRebaseMode(): InteractiveRebaseMode { vscode.workspace.getConfiguration('gitGraphPlus').get('interactiveRebase.mode', 'ui'), ); } + +/** + * Reads `gitGraphPlus.avatarOverrides` — an object mapping commit-author emails + * to explicit https avatar URLs. It lets an author whose email has neither a + * Gravatar account nor a GitHub noreply address still show a real avatar. + * Keys are normalized (trimmed + lowercased); entries whose value is empty or + * not an https URL are ignored (the fetch is https-only). + */ +export function readAvatarOverrides(): Record { + const raw = vscode.workspace + .getConfiguration('gitGraphPlus') + .get>('avatarOverrides', {}); + const out: Record = {}; + if (raw && typeof raw === 'object') { + for (const [email, url] of Object.entries(raw)) { + if (typeof url === 'string' && url.trim().startsWith('https://')) { + out[email.trim().toLowerCase()] = url.trim(); + } + } + } + return out; +}