Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
14 changes: 14 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
33 changes: 33 additions & 0 deletions src/__tests__/manifest.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, {
type?: unknown;
enum?: unknown;
default?: unknown;
scope?: unknown;
}>;
};
};
}

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',
});
});
});
21 changes: 16 additions & 5 deletions src/panels/MainPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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<string>('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;
}
Expand Down Expand Up @@ -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<string>('locale', 'auto');
const locale = localeSetting === 'auto' ? (vscode.env.language || 'en') : localeSetting;
Expand Down Expand Up @@ -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 ---
Expand Down
73 changes: 71 additions & 2 deletions src/panels/__tests__/MainPanel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn> } },
repos: [] as Array<{ path: string; name: string; type: string }>,
Expand Down Expand Up @@ -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() },
Expand All @@ -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';
Expand Down Expand Up @@ -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');
});

Expand Down Expand Up @@ -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());
Expand Down
38 changes: 38 additions & 0 deletions src/services/__tests__/avatar-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
14 changes: 12 additions & 2 deletions src/services/avatar-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -36,20 +38,22 @@ export class AvatarCache {
private inflight = new Map<string, Promise<string | null>>();
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<string | null> {
const norm = normalizeEmail(email);
const key = `${norm}:${size}`;
const key = `${this.source}:${norm}:${size}`;

const mem = this.memory.get(key);
if (mem !== undefined) {
Expand All @@ -68,6 +72,12 @@ export class AvatarCache {
}

private async load(key: string, normEmail: string, size: number): Promise<string | null> {
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;

Expand Down
51 changes: 51 additions & 0 deletions src/services/retro-avatar.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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 = [
'<path d="M2 1h4v1H2zM1 2h1v2H1zM6 2h1v2H6z"/>',
'<path d="M1 1h5v1h1v2H6V3H5V2H4v1H2v1H1z"/>',
'<path d="M2 1h4v1h1v1H5V2H4v1H2v1H1V2h1z"/>',
'<path d="M1 1h6v2H6V2H5v1H3V2H2v2H1z"/>',
][hairStyle];
const eyes = [
'<path d="M2 4h1v1H2zM5 4h1v1H5z"/>',
'<path d="M2 4h2v1H2zM5 4h1v1H5z"/>',
'<path d="M2 4h1v1H2zM4 4h2v1H4z"/>',
][eyeStyle];
const mouth = [
'<path d="M3 6h2v1H3z"/>',
'<path d="M2 6h1v1h3V6h1v1H6v1H3V7H2z"/>',
'<path d="M3 6h1v1h1V6h1v1H5v1H4V7H3z"/>',
][mouthStyle];

const svg = [
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 8 8" shape-rendering="crispEdges">`,
`<path fill="${background}" d="M0 0h8v8H0z"/>`,
`<path fill="${shirt}" d="M1 8V7h1V6h4v1h1v1z"/>`,
`<path fill="${skin}" d="M2 2h4v1h1v3H6v1H2V6H1V3h1z"/>`,
`<g fill="${hair}">${hairPixels}</g>`,
`<g fill="#25282f">${eyes}${mouth}</g>`,
'</svg>',
].join('');

return `data:image/svg+xml;base64,${Buffer.from(svg, 'utf8').toString('base64')}`;
}
5 changes: 3 additions & 2 deletions src/utils/message-bus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' };

Expand Down Expand Up @@ -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[] } }
Expand Down
Loading