From 341fca891ebe481577e6bb156290ffce26b6635a Mon Sep 17 00:00:00 2001 From: Patrick Cheung Date: Fri, 25 Sep 2026 18:21:20 +0800 Subject: [PATCH] feat(avatars): option to hide avatars and show author names only --- package.json | 5 +++ src/panels/MainPanel.ts | 6 +++- src/utils/__tests__/config.test.ts | 25 +++++++++++++- src/utils/config.ts | 9 +++++ src/utils/message-bus.ts | 1 + webview-ui/src/App.svelte | 3 ++ webview-ui/src/__tests__/App.test.ts | 9 +++++ .../components/commit/CommitDetails.svelte | 8 +++-- .../commit/__tests__/CommitDetails.test.ts | 34 +++++++++++++++++++ .../components/common/CommitHoverCard.svelte | 4 ++- .../src/components/common/StatsView.svelte | 4 ++- .../src/components/graph/CommitGraph.svelte | 4 ++- .../graph/__tests__/CommitGraph.test.ts | 27 +++++++++++++++ .../src/lib/stores/__tests__/avatars.test.ts | 30 +++++++++++++++- webview-ui/src/lib/stores/avatars.svelte.ts | 8 +++++ 15 files changed, 169 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 8a1c2932..3fc2fc11 100644 --- a/package.json +++ b/package.json @@ -409,6 +409,11 @@ "minimum": 1, "markdownDescription": "Number of additional commits fetched each time the **Load more commits** button is clicked." }, + "gitGraphPlus.showAvatars": { + "type": "boolean", + "default": true, + "markdownDescription": "Show author/committer avatars in the graph, hover cards, commit details, and stats view. When off, only names are shown and avatars are not requested or fetched." + }, "gitGraphPlus.interactiveRebase.mode": { "type": "string", "enum": ["ui", "classic"], diff --git a/src/panels/MainPanel.ts b/src/panels/MainPanel.ts index 5869905d..3facd431 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, readShowAvatars } from '../utils/config'; import { buildClassicRebaseCommand } from '../git/classic-rebase'; import { buildFullGraph } from '../git/git-graph-builder'; import { compileBranchColorRules, makeBranchColorResolver } from '../git/branch-color-resolver'; @@ -226,6 +226,9 @@ export class MainPanel { if (e.affectsConfiguration('gitGraphPlus.loadMoreCommitCount')) { this.post({ type: 'setLoadMoreCount', payload: { count: readLoadMoreCommitCount() } }); } + if (e.affectsConfiguration('gitGraphPlus.showAvatars')) { + this.post({ type: 'setShowAvatars', payload: { enabled: readShowAvatars() } }); + } if (e.affectsConfiguration('gitGraphPlus.branchColors')) { this.refreshAll(); } @@ -256,6 +259,7 @@ export class MainPanel { this.post({ type: 'setGraphColors', payload: { colors: this.readGraphColors() } }); this.post({ type: 'setLoadMoreCount', payload: { count: readLoadMoreCommitCount() } }); this.post({ type: 'setInteractiveRebaseMode', payload: { mode: readInteractiveRebaseMode() } }); + this.post({ type: 'setShowAvatars', payload: { enabled: readShowAvatars() } }); void this.postCommitLinkRules(); this.panel.webview.onDidReceiveMessage( diff --git a/src/utils/__tests__/config.test.ts b/src/utils/__tests__/config.test.ts index 63667d34..2858566a 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, readShowAvatars } from '../config'; describe('readTimeoutMs', () => { // Back-compat alias so the existing timeout cases below read naturally. @@ -101,3 +101,26 @@ describe('readLoadMoreCommitCount', () => { expect(readLoadMoreCommitCount()).toBe(50); }); }); + +describe('readShowAvatars', () => { + beforeEach(() => { h.values = {}; }); + + it('defaults to true when unset', () => { + expect(readShowAvatars()).toBe(true); + }); + + it('returns false when explicitly disabled', () => { + h.values.showAvatars = false; + expect(readShowAvatars()).toBe(false); + }); + + it('returns true when explicitly enabled', () => { + h.values.showAvatars = true; + expect(readShowAvatars()).toBe(true); + }); + + it('falls back to true for a non-boolean value', () => { + h.values.showAvatars = 'no'; + expect(readShowAvatars()).toBe(true); + }); +}); diff --git a/src/utils/config.ts b/src/utils/config.ts index fa8fabed..e6057af9 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -47,3 +47,12 @@ export function readInteractiveRebaseMode(): InteractiveRebaseMode { vscode.workspace.getConfiguration('gitGraphPlus').get('interactiveRebase.mode', 'ui'), ); } + +/** + * Reads `gitGraphPlus.showAvatars` — whether author/committer avatars are + * shown in the graph, hover cards, commit details, and stats view. Defaults to + * true; only an explicit `false` disables them. + */ +export function readShowAvatars(): boolean { + return vscode.workspace.getConfiguration('gitGraphPlus').get('showAvatars', true) !== false; +} diff --git a/src/utils/message-bus.ts b/src/utils/message-bus.ts index deec2ab7..ba7893fc 100644 --- a/src/utils/message-bus.ts +++ b/src/utils/message-bus.ts @@ -159,6 +159,7 @@ export type ExtensionMessage = | { type: 'setDefaults'; payload: ModalDefaults } | { type: 'setLoadMoreCount'; payload: { count: number } } | { type: 'setBadgeBarThickness'; payload: { width: number } } + | { type: 'setShowAvatars'; payload: { enabled: boolean } } | { type: 'setGraphColors'; payload: { colors: string[] } } | { type: 'setCommitLinkRules'; payload: { rules: LinkRule[] } } | { type: 'repoList'; payload: { repos: Array<{ path: string; name: string; type: 'root' | 'submodule' | 'nested' }>; active: string } } diff --git a/webview-ui/src/App.svelte b/webview-ui/src/App.svelte index c649320f..fea1cb08 100644 --- a/webview-ui/src/App.svelte +++ b/webview-ui/src/App.svelte @@ -112,6 +112,9 @@ import AmendModal from './components/modals/AmendModal.svelte'; case 'setBadgeBarThickness': uiStore.badgeBarWidth = msg.payload.width; break; + case 'setShowAvatars': + avatarStore.setEnabled(msg.payload.enabled); + break; case 'setLoadMoreCount': uiStore.loadMoreCount = msg.payload.count; break; diff --git a/webview-ui/src/__tests__/App.test.ts b/webview-ui/src/__tests__/App.test.ts index 69b013b7..0849aa25 100644 --- a/webview-ui/src/__tests__/App.test.ts +++ b/webview-ui/src/__tests__/App.test.ts @@ -6,6 +6,7 @@ import { commitStore } from '../lib/stores/commits.svelte'; import { branchStore } from '../lib/stores/branches.svelte'; import { uiStore } from '../lib/stores/ui.svelte'; import { modalStore } from '../lib/stores/modals.svelte'; +import { avatarStore } from '../lib/stores/avatars.svelte'; function postMsg(type: string, payload?: unknown) { window.dispatchEvent(new MessageEvent('message', { data: { type, payload } })); @@ -103,6 +104,14 @@ describe('App — message handling', () => { }); }); + it('setShowAvatars toggles avatarStore.enabled', async () => { + render(App); + postMsg('setShowAvatars', { enabled: false }); + await waitFor(() => expect(avatarStore.enabled).toBe(false)); + postMsg('setShowAvatars', { enabled: true }); + await waitFor(() => expect(avatarStore.enabled).toBe(true)); + }); + it('repoList populates uiStore.repos and activeRepo', async () => { render(App); postMsg('repoList', { diff --git a/webview-ui/src/components/commit/CommitDetails.svelte b/webview-ui/src/components/commit/CommitDetails.svelte index a9eb9b45..b4741b0d 100644 --- a/webview-ui/src/components/commit/CommitDetails.svelte +++ b/webview-ui/src/components/commit/CommitDetails.svelte @@ -624,7 +624,9 @@
{t('details.author')}
- + {#if avatarStore.enabled} + + {/if}
{commit.author.name} @@ -645,7 +647,9 @@
{t('details.committer')}
- + {#if avatarStore.enabled} + + {/if}
{commit.committer.name} diff --git a/webview-ui/src/components/commit/__tests__/CommitDetails.test.ts b/webview-ui/src/components/commit/__tests__/CommitDetails.test.ts index 9b04ec14..8c4a8408 100644 --- a/webview-ui/src/components/commit/__tests__/CommitDetails.test.ts +++ b/webview-ui/src/components/commit/__tests__/CommitDetails.test.ts @@ -5,6 +5,7 @@ import { i18n } from '../../../lib/i18n/index.svelte'; import { commitStore } from '../../../lib/stores/commits.svelte'; import { uiStore } from '../../../lib/stores/ui.svelte'; import { modalStore } from '../../../lib/stores/modals.svelte'; +import { avatarStore } from '../../../lib/stores/avatars.svelte'; import type { Commit, DiffData } from '../../../lib/types'; function commit(over: Partial = {}): Commit { @@ -1461,3 +1462,36 @@ describe('CommitDetails — reverse changes (committed view)', () => { expect(msg!.payload).toMatchObject({ commit: 'h1', file: 'src/a.ts', hunkIndex: 0, lineIndices: [1, 2] }); }); }); + +describe('CommitDetails — avatars', () => { + afterEach(() => { + avatarStore.setEnabled(true); + }); + + it('renders author and committer avatars by default', async () => { + const { container } = render(CommitDetails, { + commit: commit({ + committer: { name: 'Bob', email: 'b@x.com', date: '2024-01-15T11:00:00Z' }, + }), + }); + await waitFor(() => container.querySelector('.person-name')); + expect(container.querySelectorAll('img.avatar-lg').length).toBe(2); + }); + + it('hides avatars but keeps author and committer names when showAvatars is off', async () => { + avatarStore.setEnabled(false); + const { container } = render(CommitDetails, { + commit: commit({ + committer: { name: 'Bob', email: 'b@x.com', date: '2024-01-15T11:00:00Z' }, + }), + }); + await waitFor(() => container.querySelector('.person-name')); + expect(container.querySelector('img.avatar-lg')).toBeFalsy(); + const names = Array.from(container.querySelectorAll('.person-name')).map(el => el.textContent ?? ''); + expect(names.some(n => n.includes('Alice'))).toBe(true); + expect(names.some(n => n.includes('Bob'))).toBe(true); + expect( + globalThis.__postedMessages.some(m => (m.data as { type?: string }).type === 'getAvatar') + ).toBe(false); + }); +}); diff --git a/webview-ui/src/components/common/CommitHoverCard.svelte b/webview-ui/src/components/common/CommitHoverCard.svelte index a1468fe2..ff048615 100644 --- a/webview-ui/src/components/common/CommitHoverCard.svelte +++ b/webview-ui/src/components/common/CommitHoverCard.svelte @@ -54,7 +54,9 @@ >
- + {#if avatarStore.enabled} + + {/if}
{commit.author.name} diff --git a/webview-ui/src/components/common/StatsView.svelte b/webview-ui/src/components/common/StatsView.svelte index 392770a0..101e0d18 100644 --- a/webview-ui/src/components/common/StatsView.svelte +++ b/webview-ui/src/components/common/StatsView.svelte @@ -65,7 +65,9 @@ {#each byAuthor as author, i}
#{i + 1} - + {#if avatarStore.enabled} + + {/if}
{author.author}
diff --git a/webview-ui/src/components/graph/CommitGraph.svelte b/webview-ui/src/components/graph/CommitGraph.svelte index aa5d2742..afebc87f 100644 --- a/webview-ui/src/components/graph/CommitGraph.svelte +++ b/webview-ui/src/components/graph/CommitGraph.svelte @@ -1362,7 +1362,9 @@
{#if commit.hash !== 'UNCOMMITTED'} - + {#if avatarStore.enabled} + + {/if} {commit.author.name} {#if commit.signatureStatus && commit.signatureStatus !== 'none'} diff --git a/webview-ui/src/components/graph/__tests__/CommitGraph.test.ts b/webview-ui/src/components/graph/__tests__/CommitGraph.test.ts index 096dbd59..40bcb172 100644 --- a/webview-ui/src/components/graph/__tests__/CommitGraph.test.ts +++ b/webview-ui/src/components/graph/__tests__/CommitGraph.test.ts @@ -6,6 +6,7 @@ import { commitStore } from '../../../lib/stores/commits.svelte'; import { branchStore } from '../../../lib/stores/branches.svelte'; import { uiStore } from '../../../lib/stores/ui.svelte'; import { modalStore } from '../../../lib/stores/modals.svelte'; +import { avatarStore } from '../../../lib/stores/avatars.svelte'; import { i18n } from '../../../lib/i18n/index.svelte'; import type { Commit, CommitGraphData } from '../../../lib/types'; @@ -418,3 +419,29 @@ describe('CommitGraph signature icon', () => { uiStore.exitMultiSelect(); }); }); + +describe('CommitGraph avatars', () => { + afterEach(() => { + avatarStore.setEnabled(true); + }); + + it('renders the author avatar by default', async () => { + commitStore.setData(makeGraphData([makeCommit('h1', 'first')])); + const { container } = render(CommitGraph, {}); + await tick(); + expect(container.querySelector('img.avatar-sm')).toBeTruthy(); + expect(container.querySelector('.author-name')?.textContent).toBe('A'); + }); + + it('hides the avatar and still shows the author name when showAvatars is off', async () => { + avatarStore.setEnabled(false); + commitStore.setData(makeGraphData([makeCommit('h1', 'first')])); + const { container } = render(CommitGraph, {}); + await tick(); + expect(container.querySelector('img.avatar-sm')).toBeFalsy(); + expect(container.querySelector('.author-name')?.textContent).toBe('A'); + expect( + globalThis.__postedMessages.some(m => (m.data as { type?: string }).type === 'getAvatar') + ).toBe(false); + }); +}); diff --git a/webview-ui/src/lib/stores/__tests__/avatars.test.ts b/webview-ui/src/lib/stores/__tests__/avatars.test.ts index 6e967e93..44980a22 100644 --- a/webview-ui/src/lib/stores/__tests__/avatars.test.ts +++ b/webview-ui/src/lib/stores/__tests__/avatars.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, afterEach } from 'vitest'; import { avatarStore } from '../avatars.svelte'; const TRANSPARENT_PIXEL = @@ -50,3 +50,31 @@ describe('avatarStore', () => { expect(avatarStore.url(' fifth@example.com ', 32)).toBe('data:image/png;base64,BBBB'); }); }); + +describe('avatarStore — disabled (showAvatars off)', () => { + afterEach(() => { + avatarStore.setEnabled(true); + }); + + it('returns the transparent pixel and posts no getAvatar while disabled', () => { + avatarStore.setEnabled(false); + globalThis.__postedMessages = []; + expect(avatarStore.url('hidden@example.com', 32)).toBe(TRANSPARENT_PIXEL); + expect(postedTypes()).not.toContain('getAvatar'); + expect(globalThis.__postedMessages.length).toBe(0); + }); + + it('resumes requesting and serving avatars after re-enabling', () => { + avatarStore.setEnabled(false); + globalThis.__postedMessages = []; + expect(avatarStore.url('resume@example.com', 32)).toBe(TRANSPARENT_PIXEL); + expect(postedTypes()).not.toContain('getAvatar'); + + avatarStore.setEnabled(true); + expect(avatarStore.url('resume@example.com', 32)).toBe(TRANSPARENT_PIXEL); + expect(postedTypes()).toContain('getAvatar'); + + avatarStore.receive('resume@example.com', 32, 'data:image/png;base64,RESUMED'); + expect(avatarStore.url('resume@example.com', 32)).toBe('data:image/png;base64,RESUMED'); + }); +}); diff --git a/webview-ui/src/lib/stores/avatars.svelte.ts b/webview-ui/src/lib/stores/avatars.svelte.ts index 9dab2e24..cfaa2f76 100644 --- a/webview-ui/src/lib/stores/avatars.svelte.ts +++ b/webview-ui/src/lib/stores/avatars.svelte.ts @@ -21,12 +21,20 @@ class AvatarStore { // key -> data URI; '' means resolved-but-unavailable (failed fetch). private cache = new SvelteMap(); private requested = new Set(); + /** Mirrors `gitGraphPlus.showAvatars`; when false no avatar is requested + * and `url()` hands back the transparent pixel for every key. */ + enabled = $state(true); + + setEnabled(value: boolean): void { + this.enabled = value; + } private key(email: string, size: number): string { return `${email.trim().toLowerCase()}:${size}`; } url(email: string, size: number): string { + if (!this.enabled) return TRANSPARENT_PIXEL; const key = this.key(email, size); const hit = this.cache.get(key); if (hit === undefined && !this.requested.has(key)) {