From ca3c3f5dcaa9eb10ca3be5bbe883f7ac5ca91d03 Mon Sep 17 00:00:00 2001 From: benshi <807629978@qq.com> Date: Fri, 7 Aug 2026 05:53:10 +0000 Subject: [PATCH] feat(avatar): add support for configurable avatar sources with Gravatar and Retro options --- README.md | 1 + package.json | 14 ++++ src/__tests__/manifest.test.ts | 33 +++++++++ src/panels/MainPanel.ts | 21 ++++-- src/panels/__tests__/MainPanel.test.ts | 73 ++++++++++++++++++- src/services/__tests__/avatar-cache.test.ts | 38 ++++++++++ src/services/avatar-cache.ts | 14 +++- src/services/retro-avatar.ts | 51 +++++++++++++ src/utils/message-bus.ts | 5 +- webview-ui/src/App.svelte | 10 ++- .../src/lib/stores/__tests__/avatars.test.ts | 45 +++++++++++- webview-ui/src/lib/stores/avatars.svelte.ts | 15 +++- 12 files changed, 304 insertions(+), 16 deletions(-) create mode 100644 src/__tests__/manifest.test.ts create mode 100644 src/services/retro-avatar.ts diff --git a/README.md b/README.md index 395532d..23f8fdb 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,7 @@ A modern, full-featured Git GUI for VS Code. Visualize your commit history, mana | `gitGraphPlus.loadMoreCommitCount` | `50` | Extra commits fetched per **Load more commits** click | | `gitGraphPlus.locale` | `auto` | UI language (`auto`, `en`, `ko`, `zh-cn`) | | `gitGraphPlus.graphSortOrder` | `topological` | Commit sort order (`topological`, `date`, `author-date`) | +| `gitGraphPlus.avatarSource` | `gravatar` | Author avatar source (`gravatar` online/cache, `retro` fully offline) | | `gitGraphPlus.interactiveRebase.mode` | `ui` | Interactive rebase mode (`ui` visual editor, `classic` `git rebase -i` in a terminal) | | `gitGraphPlus.showSignatureStatus` | `true` | Show GPG/SSH signature status in the graph | | `gitGraphPlus.commitMessageLinks` | `[]` | Custom `{ pattern, url }` regex rules that turn commit-message text into clickable links | diff --git a/package.json b/package.json index 8a1c293..4c3d0ca 100644 --- a/package.json +++ b/package.json @@ -434,6 +434,20 @@ "default": "topological", "description": "Commit sort order in the graph" }, + "gitGraphPlus.avatarSource": { + "type": "string", + "enum": [ + "gravatar", + "retro" + ], + "enumDescriptions": [ + "Download each author's Gravatar and cache it locally", + "Generate a deterministic retro pixel avatar locally without network access" + ], + "default": "gravatar", + "scope": "application", + "description": "Source used for author avatars" + }, "gitGraphPlus.showSignatureStatus": { "type": "boolean", "default": true, diff --git a/src/__tests__/manifest.test.ts b/src/__tests__/manifest.test.ts new file mode 100644 index 0000000..91050f4 --- /dev/null +++ b/src/__tests__/manifest.test.ts @@ -0,0 +1,33 @@ +import { readFileSync } from 'fs'; +import * as path from 'path'; +import { describe, expect, it } from 'vitest'; + +interface ExtensionManifest { + contributes: { + configuration: { + properties: Record; + }; + }; +} + +describe('extension manifest defaults', () => { + it('offers Gravatar and offline Retro avatar sources with Gravatar as the default', () => { + const manifest = JSON.parse( + readFileSync(path.resolve(process.cwd(), 'package.json'), 'utf8'), + ) as ExtensionManifest; + + expect( + manifest.contributes.configuration.properties['gitGraphPlus.avatarSource'], + ).toMatchObject({ + type: 'string', + enum: ['gravatar', 'retro'], + default: 'gravatar', + scope: 'application', + }); + }); +}); diff --git a/src/panels/MainPanel.ts b/src/panels/MainPanel.ts index 5869905..07150cf 100644 --- a/src/panels/MainPanel.ts +++ b/src/panels/MainPanel.ts @@ -12,7 +12,7 @@ import { compileBranchColorRules, makeBranchColorResolver } from '../git/branch- import { resolveGraphColors } from '../git/graph-colors'; import { triggerVSCodeGitAuth } from '../git/vscode-git-bridge'; import { FileWatcher } from '../services/file-watcher'; -import { AvatarCache } from '../services/avatar-cache'; +import { AvatarCache, type AvatarSource } from '../services/avatar-cache'; import { resolveGitDirs, shouldRefreshGraph } from '../services/file-watcher-helpers'; import { RepoDiscoveryService, RepoInfo } from '../services/repo-discovery'; import type { WebviewMessage, ModalDefaults } from '../utils/message-bus'; @@ -35,6 +35,7 @@ export class MainPanel { // each one re-fetching from gravatar.com (issue #38). private static avatarCacheDir: string | undefined = undefined; private static avatarCache: AvatarCache | undefined = undefined; + private static avatarCacheSource: AvatarSource | undefined = undefined; private readonly panel: vscode.WebviewPanel; private readonly extensionUri: vscode.Uri; @@ -89,8 +90,14 @@ export class MainPanel { } private static getAvatarCache(): AvatarCache { - if (!this.avatarCache) { - this.avatarCache = new AvatarCache(this.avatarCacheDir ?? null); + const configuredSource = vscode.workspace + .getConfiguration('gitGraphPlus') + .get('avatarSource', 'gravatar'); + const source: AvatarSource = configuredSource === 'gravatar' ? 'gravatar' : 'retro'; + + if (!this.avatarCache || this.avatarCacheSource !== source) { + this.avatarCache = new AvatarCache(this.avatarCacheDir ?? null, undefined, { source }); + this.avatarCacheSource = source; } return this.avatarCache; } @@ -209,6 +216,10 @@ export class MainPanel { if (e.affectsConfiguration('gitGraphPlus.graphSortOrder')) { this.refreshAll(); } + if (e.affectsConfiguration('gitGraphPlus.avatarSource')) { + MainPanel.avatarCache = undefined; + this.post({ type: 'resetAvatars' }); + } if (e.affectsConfiguration('gitGraphPlus.locale')) { const localeSetting = vscode.workspace.getConfiguration('gitGraphPlus').get('locale', 'auto'); const locale = localeSetting === 'auto' ? (vscode.env.language || 'en') : localeSetting; @@ -1443,9 +1454,9 @@ export class MainPanel { } // --- Avatar (cached in the extension host; see AvatarCache) --- case 'getAvatar': { - const { email, size } = message.payload; + const { email, size, generation } = message.payload; const dataUri = await MainPanel.getAvatarCache().get(email, size); - this.post({ type: 'avatarData', payload: { email, size, dataUri } }); + this.post({ type: 'avatarData', payload: { email, size, dataUri, generation } }); break; } // --- Image Diff --- diff --git a/src/panels/__tests__/MainPanel.test.ts b/src/panels/__tests__/MainPanel.test.ts index 76051ee..3d34a22 100644 --- a/src/panels/__tests__/MainPanel.test.ts +++ b/src/panels/__tests__/MainPanel.test.ts @@ -34,6 +34,10 @@ const H = vi.hoisted(() => { }; return { git, + avatarGet: vi.fn(async () => 'data:image/png;base64,AAAA'), + avatarOptions: undefined as { source?: string } | undefined, + avatarSource: undefined as string | undefined, + configurationHandler: null as null | ((event: { affectsConfiguration(section: string): boolean }) => void), messageHandler: null as null | ((m: unknown) => unknown), panel: null as null | { webview: { postMessage: ReturnType } }, repos: [] as Array<{ path: string; name: string; type: string }>, @@ -70,10 +74,18 @@ vi.mock('vscode', () => { showSaveDialog: vi.fn(async () => undefined), }, workspace: { - getConfiguration: () => ({ get: (_k: string, d?: unknown) => d }), + getConfiguration: (section?: string) => ({ + get: (key: string, d?: unknown) => { + if (section === 'gitGraphPlus' && key === 'avatarSource') return H.avatarSource ?? d; + return d; + }, + }), getWorkspaceFolder: () => ({ uri: { fsPath: '/repo' } }), workspaceFolders: [{ uri: { fsPath: '/repo' } }], - onDidChangeConfiguration: () => ({ dispose() {} }), + onDidChangeConfiguration: (cb: (event: { affectsConfiguration(section: string): boolean }) => void) => { + H.configurationHandler = cb; + return { dispose() {} }; + }, fs: { writeFile: vi.fn(async () => {}) }, }, commands: { executeCommand: vi.fn() }, @@ -95,6 +107,18 @@ vi.mock('../../git/git-service', async (orig) => { vi.mock('../../services/file-watcher', () => ({ FileWatcher: class { enabled = true; suppress() {} dispose() {} } })); vi.mock('../../services/repo-discovery', () => ({ RepoDiscoveryService: { discoverRepos: vi.fn(async () => H.repos), clearCache: vi.fn() } })); vi.mock('../../git/vscode-git-bridge', () => ({ triggerVSCodeGitAuth: vi.fn(async () => false) })); +vi.mock('../../services/avatar-cache', () => ({ + AvatarCache: class { + constructor( + _cacheDir: string | null, + _fetcher?: unknown, + options?: { source?: string }, + ) { + H.avatarOptions = options; + } + get = H.avatarGet; + }, +})); import { MainPanel } from '../MainPanel'; import { GitError } from '../../git/git-service'; @@ -127,8 +151,13 @@ beforeEach(() => { H.git.showCommitDiff.mockResolvedValue([]); H.git.fileExistsAtRef.mockResolvedValue(true); H.git.getEmptyTreeRef.mockResolvedValue('4b825dc642cb6eb9a060e54bf8d69288fbee4904'); + H.avatarGet.mockReset(); + H.avatarGet.mockResolvedValue('data:image/png;base64,AAAA'); + H.avatarOptions = undefined; + H.avatarSource = undefined; H.repos = [{ path: '/repo', name: 'repo', type: 'root' }]; (MainPanel as unknown as { currentPanel: unknown }).currentPanel = undefined; + (MainPanel as unknown as { avatarCache: unknown }).avatarCache = undefined; MainPanel.createOrShow(extUri, '/repo'); }); @@ -269,6 +298,46 @@ describe('MainPanel message routing', () => { expect(data.payload!.hash).toBe('h1'); }); + it('getAvatar uses the selected source and posts the resolved image', async () => { + await dispatch({ + type: 'getAvatar', + payload: { email: 'author@example.com', size: 20, generation: 7 }, + }); + + expect(H.avatarOptions).toEqual({ source: 'gravatar' }); + expect(H.avatarGet).toHaveBeenCalledWith('author@example.com', 20); + expect(postedOfType('avatarData').at(-1)?.payload).toEqual({ + email: 'author@example.com', + size: 20, + dataUri: 'data:image/png;base64,AAAA', + generation: 7, + }); + }); + + it('getAvatar uses the selected offline Retro source', async () => { + H.avatarSource = 'retro'; + + await dispatch({ type: 'getAvatar', payload: { email: 'author@example.com', size: 20 } }); + + expect(H.avatarOptions).toEqual({ source: 'retro' }); + }); + + it('getAvatar falls back to Retro for an invalid configured source', async () => { + H.avatarSource = 'unexpected'; + + await dispatch({ type: 'getAvatar', payload: { email: 'author@example.com', size: 20 } }); + + expect(H.avatarOptions).toEqual({ source: 'retro' }); + }); + + it('asks the webview to reset when the avatar source changes', () => { + H.configurationHandler?.({ + affectsConfiguration: (section) => section === 'gitGraphPlus.avatarSource', + }); + + expect(postedOfType('resetAvatars')).toHaveLength(1); + }); + it('merge calls GitService.merge then refreshes the whole view', async () => { await dispatch({ type: 'merge', payload: { branch: 'feature' } }); expect(H.git.merge).toHaveBeenCalledWith('feature', expect.anything()); diff --git a/src/services/__tests__/avatar-cache.test.ts b/src/services/__tests__/avatar-cache.test.ts index 353152f..a311330 100644 --- a/src/services/__tests__/avatar-cache.test.ts +++ b/src/services/__tests__/avatar-cache.test.ts @@ -39,6 +39,44 @@ describe('AvatarCache', () => { expect(calls[0]).toContain('s=32'); }); + it('generates a retro avatar offline', async () => { + const { fetcher, calls } = makeFetcher(); + const cache = new AvatarCache(null, fetcher, { source: 'retro' }); + + const uri = await cache.get('Alice@Example.com', 32); + + expect(uri).toMatch(/^data:image\/svg\+xml;base64,/); + expect(calls).toHaveLength(0); + }); + + it('generates the same Retro avatar for equivalent emails across instances', async () => { + const first = new AvatarCache(null, makeFetcher().fetcher, { source: 'retro' }); + const second = new AvatarCache(null, makeFetcher().fetcher, { source: 'retro' }); + + expect(await first.get('Alice@Example.com', 32)).toBe( + await second.get(' alice@example.com ', 32), + ); + }); + + it('generates different Retro avatars for different emails', async () => { + const cache = new AvatarCache(null, makeFetcher().fetcher, { source: 'retro' }); + + expect(await cache.get('alice@example.com', 32)).not.toBe( + await cache.get('bob@example.com', 32), + ); + }); + + it('does not reuse a Gravatar disk entry for the Retro source', async () => { + await new AvatarCache(tmpDir, makeFetcher().fetcher).get('alice@example.com', 32); + const { fetcher, calls } = makeFetcher(); + + const uri = await new AvatarCache(tmpDir, fetcher, { source: 'retro' }) + .get('alice@example.com', 32); + + expect(uri).toMatch(/^data:image\/svg\+xml;base64,/); + expect(calls).toHaveLength(0); + }); + 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..4d4096e 100644 --- a/src/services/avatar-cache.ts +++ b/src/services/avatar-cache.ts @@ -2,11 +2,13 @@ import { createHash } from 'crypto'; import { promises as fs } from 'fs'; import * as path from 'path'; import * as https from 'https'; +import { generateRetroAvatar } from './retro-avatar'; /** Fetches the raw bytes for an avatar URL. Returns null on any failure so the * cache can degrade gracefully (the webview falls back to no avatar). Injected * in tests so they never touch the network. */ export type AvatarFetcher = (url: string) => Promise<{ data: Buffer; contentType: string } | null>; +export type AvatarSource = 'gravatar' | 'retro'; const MAX_MEMORY_ENTRIES = 500; const FETCH_TIMEOUT_MS = 10000; @@ -36,20 +38,22 @@ export class AvatarCache { private inflight = new Map>(); private maxDiskEntries: number; private ttlMs: number; + private source: AvatarSource; constructor( private cacheDir: string | null = null, private fetcher: AvatarFetcher = defaultFetcher, - opts?: { maxDiskEntries?: number; ttlMs?: number }, + opts?: { maxDiskEntries?: number; ttlMs?: number; source?: AvatarSource }, ) { this.maxDiskEntries = opts?.maxDiskEntries ?? DEFAULT_MAX_DISK_ENTRIES; this.ttlMs = opts?.ttlMs ?? DEFAULT_TTL_MS; + this.source = opts?.source === 'retro' ? 'retro' : 'gravatar'; } /** Returns a base64 data URI for the avatar, or null if it cannot be loaded. */ async get(email: string, size: number): Promise { const norm = normalizeEmail(email); - const key = `${norm}:${size}`; + const key = `${this.source}:${norm}:${size}`; const mem = this.memory.get(key); if (mem !== undefined) { @@ -68,6 +72,12 @@ export class AvatarCache { } private async load(key: string, normEmail: string, size: number): Promise { + if (this.source === 'retro') { + const dataUri = generateRetroAvatar(normEmail, size); + this.remember(key, dataUri); + return dataUri; + } + const hash = md5(normEmail); const diskFile = this.cacheDir ? path.join(this.cacheDir, `${hash}-${size}`) : null; diff --git a/src/services/retro-avatar.ts b/src/services/retro-avatar.ts new file mode 100644 index 0000000..4526430 --- /dev/null +++ b/src/services/retro-avatar.ts @@ -0,0 +1,51 @@ +import { createHash } from 'crypto'; + +const BACKGROUNDS = ['#5b8def', '#9b72cf', '#45a675', '#d17b49', '#d05f7a', '#4f9da6']; +const SKIN_TONES = ['#f6d0a9', '#e8b482', '#c9855b', '#9b5d3f', '#70422f']; +const HAIR_COLORS = ['#2d211b', '#51352a', '#7a4d2a', '#c28a45', '#25282f']; +const SHIRT_COLORS = ['#f05d5e', '#4f86f7', '#43aa8b', '#f6bd60', '#8e6ccf']; + +function pick(values: readonly T[], byte: number): T { + return values[byte % values.length]; +} + +/** Generates a deterministic pixel-art face without embedding the source email. */ +export function generateRetroAvatar(normalizedEmail: string, size: number): string { + const bytes = createHash('sha256').update(normalizedEmail).digest(); + const background = pick(BACKGROUNDS, bytes[0]); + const skin = pick(SKIN_TONES, bytes[1]); + const hair = pick(HAIR_COLORS, bytes[2]); + const shirt = pick(SHIRT_COLORS, bytes[3]); + const hairStyle = bytes[4] % 4; + const eyeStyle = bytes[5] % 3; + const mouthStyle = bytes[6] % 3; + + const hairPixels = [ + '', + '', + '', + '', + ][hairStyle]; + const eyes = [ + '', + '', + '', + ][eyeStyle]; + const mouth = [ + '', + '', + '', + ][mouthStyle]; + + const svg = [ + ``, + ``, + ``, + ``, + `${hairPixels}`, + `${eyes}${mouth}`, + '', + ].join(''); + + return `data:image/svg+xml;base64,${Buffer.from(svg, 'utf8').toString('base64')}`; +} diff --git a/src/utils/message-bus.ts b/src/utils/message-bus.ts index deec2ab..912a8b2 100644 --- a/src/utils/message-bus.ts +++ b/src/utils/message-bus.ts @@ -125,7 +125,7 @@ export type WebviewMessage = | { type: 'getUncommittedDiff' } | { type: 'getUncommittedFileDiff'; payload: { file: string; staged: boolean } } | { type: 'getMultiCommitSections'; payload: { hashes: string[] } } - | { type: 'getAvatar'; payload: { email: string; size: number } } + | { type: 'getAvatar'; payload: { email: string; size: number; generation?: number } } | { type: 'openExternalUrl'; payload: { url: string } } | { type: 'openExtensionSettings' }; @@ -166,7 +166,8 @@ export type ExtensionMessage = | { type: 'uncommittedDiffData'; payload: { staged: Array<{ path: string; status: string }>; unstaged: Array<{ path: string; status: string }> } } | { type: 'multiCommitSectionsData'; payload: { files: Array<{ path: string; status: string }>; sections: Array<{ file: string; commit: string; diff: DiffData }> } } | { type: 'imageData'; payload: { ref: string; path: string; base64: string; mimeType: string } } - | { type: 'avatarData'; payload: { email: string; size: number; dataUri: string | null } } + | { type: 'avatarData'; payload: { email: string; size: number; dataUri: string | null; generation?: number } } + | { type: 'resetAvatars' } | { type: 'conflictData'; payload: { operation: string; files: Array<{ path: string; resolved: boolean }> } } | { type: 'flowStatus'; payload: { installed: boolean; initialized: boolean; config: { productionBranch: string; developBranch: string; featurePrefix: string; releasePrefix: string; hotfixPrefix: string; versionTagPrefix: string } | null } } | { type: 'flowBranches'; payload: { features: string[]; releases: string[]; hotfixes: string[] } } diff --git a/webview-ui/src/App.svelte b/webview-ui/src/App.svelte index c649320..35410cf 100644 --- a/webview-ui/src/App.svelte +++ b/webview-ui/src/App.svelte @@ -125,7 +125,15 @@ import AmendModal from './components/modals/AmendModal.svelte'; commitLinkRulesStore.set(msg.payload.rules); break; case 'avatarData': - avatarStore.receive(msg.payload.email, msg.payload.size, msg.payload.dataUri); + avatarStore.receive( + msg.payload.email, + msg.payload.size, + msg.payload.dataUri, + msg.payload.generation, + ); + break; + case 'resetAvatars': + avatarStore.reset(); break; case 'repoList': uiStore.repos = msg.payload.repos; diff --git a/webview-ui/src/lib/stores/__tests__/avatars.test.ts b/webview-ui/src/lib/stores/__tests__/avatars.test.ts index 6e967e9..a9dfa00 100644 --- a/webview-ui/src/lib/stores/__tests__/avatars.test.ts +++ b/webview-ui/src/lib/stores/__tests__/avatars.test.ts @@ -18,9 +18,13 @@ describe('avatarStore', () => { expect(postedTypes()).toContain('getAvatar'); const msg = globalThis.__postedMessages.at(-1)?.data as { type: string; - payload: { email: string; size: number }; + payload: { email: string; size: number; generation: number }; }; - expect(msg.payload).toEqual({ email: 'first@example.com', size: 32 }); + expect(msg.payload).toEqual({ + email: 'first@example.com', + size: 32, + generation: expect.any(Number), + }); }); it('does not re-request a key that is already pending', () => { @@ -49,4 +53,41 @@ describe('avatarStore', () => { avatarStore.receive('Fifth@Example.com', 32, 'data:image/png;base64,BBBB'); expect(avatarStore.url(' fifth@example.com ', 32)).toBe('data:image/png;base64,BBBB'); }); + + it('requests avatars again after the configured source changes', () => { + const email = 'source-switch@example.com'; + avatarStore.receive(email, 32, 'data:image/png;base64,OLD'); + const messagesBeforeReset = globalThis.__postedMessages.length; + + avatarStore.reset(); + const result = avatarStore.url(email, 32); + + expect(result).toBe(TRANSPARENT_PIXEL); + expect(globalThis.__postedMessages).toHaveLength(messagesBeforeReset + 1); + expect(globalThis.__postedMessages.at(-1)?.data).toEqual({ + type: 'getAvatar', + payload: { email, size: 32, generation: expect.any(Number) }, + }); + }); + + it('ignores a response from the avatar source used before reset', () => { + const email = 'source-race@example.com'; + avatarStore.reset(); + avatarStore.url(email, 32); + const oldRequest = globalThis.__postedMessages.at(-1)?.data as { + payload: { generation: number }; + }; + + avatarStore.reset(); + avatarStore.url(email, 32); + const newRequest = globalThis.__postedMessages.at(-1)?.data as { + payload: { generation: number }; + }; + + avatarStore.receive(email, 32, 'data:image/png;base64,OLD', oldRequest.payload.generation); + expect(avatarStore.url(email, 32)).toBe(TRANSPARENT_PIXEL); + + avatarStore.receive(email, 32, 'data:image/png;base64,NEW', newRequest.payload.generation); + expect(avatarStore.url(email, 32)).toBe('data:image/png;base64,NEW'); + }); }); diff --git a/webview-ui/src/lib/stores/avatars.svelte.ts b/webview-ui/src/lib/stores/avatars.svelte.ts index 9dab2e2..6fa8eec 100644 --- a/webview-ui/src/lib/stores/avatars.svelte.ts +++ b/webview-ui/src/lib/stores/avatars.svelte.ts @@ -21,6 +21,7 @@ class AvatarStore { // key -> data URI; '' means resolved-but-unavailable (failed fetch). private cache = new SvelteMap(); private requested = new Set(); + private generation = 0; private key(email: string, size: number): string { return `${email.trim().toLowerCase()}:${size}`; @@ -31,14 +32,24 @@ class AvatarStore { const hit = this.cache.get(key); if (hit === undefined && !this.requested.has(key)) { this.requested.add(key); - getVsCodeApi().postMessage({ type: 'getAvatar', payload: { email, size } }); + getVsCodeApi().postMessage({ + type: 'getAvatar', + payload: { email, size, generation: this.generation }, + }); } return hit || TRANSPARENT_PIXEL; } - receive(email: string, size: number, dataUri: string | null): void { + receive(email: string, size: number, dataUri: string | null, generation = this.generation): void { + if (generation !== this.generation) return; this.cache.set(this.key(email, size), dataUri ?? ''); } + + reset(): void { + this.generation += 1; + this.cache.clear(); + this.requested.clear(); + } } export const avatarStore = new AvatarStore();