diff --git a/packages/api/src/modules/activity.test.ts b/packages/api/src/modules/activity.test.ts index 6c5444a..a8ca69f 100644 --- a/packages/api/src/modules/activity.test.ts +++ b/packages/api/src/modules/activity.test.ts @@ -64,6 +64,55 @@ describe('ActivityApi.listReceivedEvents', () => { }) }) +describe('ActivityApi.getPushCommitCounts', () => { + function createCompareApi(totalByBasehead: Record) { + const compareCommitsWithBasehead = vi.fn(async ({ basehead }: { basehead: string }) => { + const value = totalByBasehead[basehead] + if (value instanceof Error) throw value + return { data: { total_commits: value } } + }) + const api = new ActivityApi({ + rest: { repos: { compareCommitsWithBasehead } }, + } as unknown as GitHubOctokit) + + return { api, compareCommitsWithBasehead } + } + + it('resolves the commit count per push ref via compare', async () => { + const { api, compareCommitsWithBasehead } = createCompareApi({ 'a1...b2': 4 }) + const result = await api.getPushCommitCounts([ + { key: 'vuejs/pinia@a1...b2', repoFullName: 'vuejs/pinia', before: 'a1', head: 'b2' }, + ]) + + expect(result).toEqual({ 'vuejs/pinia@a1...b2': 4 }) + expect(compareCommitsWithBasehead).toHaveBeenCalledWith({ + owner: 'vuejs', + repo: 'pinia', + basehead: 'a1...b2', + per_page: 1, + }) + }) + + it('marks new-branch pushes (zero base SHA) unknown without calling compare', async () => { + const { api, compareCommitsWithBasehead } = createCompareApi({}) + const result = await api.getPushCommitCounts([ + { key: 'k', repoFullName: 'vuejs/pinia', before: '0000000000000000000000000000000000000000', head: 'b2' }, + ]) + + expect(result).toEqual({ k: null }) + expect(compareCommitsWithBasehead).not.toHaveBeenCalled() + }) + + it('degrades a failed compare to an unknown count', async () => { + const { api } = createCompareApi({ 'a1...b2': new Error('422 Unprocessable') }) + const result = await api.getPushCommitCounts([ + { key: 'k', repoFullName: 'vuejs/pinia', before: 'a1', head: 'b2' }, + ]) + + expect(result).toEqual({ k: null }) + }) +}) + describe('normalizeFeedEvent', () => { it('normalizes a WatchEvent into a star payload', () => { expect(normalizeFeedEvent(rawEvent('WatchEvent', { action: 'started' }))).toEqual({ @@ -84,6 +133,8 @@ describe('normalizeFeedEvent', () => { it('strips refs/heads/ from PushEvent and keeps first-line commit messages', () => { const event = normalizeFeedEvent(rawEvent('PushEvent', { ref: 'refs/heads/main', + before: 'aaa111', + head: 'bbb222', size: 3, commits: [ { message: 'fix: cache invalidation\n\ndetails here' }, @@ -93,11 +144,29 @@ describe('normalizeFeedEvent', () => { expect(event.payload).toEqual({ kind: 'push', branch: 'main', + beforeSha: 'aaa111', + headSha: 'bbb222', commitCount: 3, commitMessages: ['fix: cache invalidation', 'feat: add devtools hook'], }) }) + it('leaves commitCount null when the reduced PushEvent payload omits size and commits', () => { + const event = normalizeFeedEvent(rawEvent('PushEvent', { + ref: 'refs/heads/main', + before: 'aaa111', + head: 'bbb222', + })) + expect(event.payload).toEqual({ + kind: 'push', + branch: 'main', + beforeSha: 'aaa111', + headSha: 'bbb222', + commitCount: null, + commitMessages: [], + }) + }) + it('normalizes CreateEvent branch and repository variants', () => { expect(normalizeFeedEvent(rawEvent('CreateEvent', { ref_type: 'branch', ref: 'feat/x' })).payload) .toEqual({ kind: 'create', refType: 'branch', ref: 'feat/x' }) diff --git a/packages/api/src/modules/activity.ts b/packages/api/src/modules/activity.ts index fcf363b..9e239c8 100644 --- a/packages/api/src/modules/activity.ts +++ b/packages/api/src/modules/activity.ts @@ -16,7 +16,7 @@ export type GitHubFeedEventPayload = | { kind: 'fork'; forkFullName: string | null } | { kind: 'create'; refType: 'repository' | 'branch' | 'tag'; ref: string | null } | { kind: 'delete'; refType: 'branch' | 'tag'; ref: string } - | { kind: 'push'; branch: string; commitCount: number; commitMessages: string[] } + | { kind: 'push'; branch: string; beforeSha: string; headSha: string; commitCount: number | null; commitMessages: string[] } | { kind: 'release'; tagName: string; releaseName: string | null; excerpt: string | null } | { kind: 'public' } | { kind: 'member'; memberLogin: string | null } @@ -120,6 +120,73 @@ export class ActivityApi { throw error } } + + // GitHub no longer ships commit counts inside PushEvent payloads (only before/head + // SHAs). Recover the real count per push via the compare endpoint, keyed by a + // caller-supplied `key` so the renderer owns the cache identity. Runs as progressive + // enhancement: concurrency-limited, and any push we can't compare stays `null`. + async getPushCommitCounts(refs: PushCommitCountRef[]): Promise> { + const result: Record = {} + const pending: PushCommitCountRef[] = [] + + for (const ref of refs) { + if (isEnrichablePushRef(ref)) { + pending.push(ref) + } else { + // New-branch pushes (before = zero SHA) and malformed refs have no comparable + // base — leave the count unknown rather than guessing. + result[ref.key] = null + } + } + + let cursor = 0 + const workerCount = Math.min(PUSH_COUNT_CONCURRENCY, pending.length) + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (cursor < pending.length) { + const ref = pending[cursor++] + result[ref.key] = await this.fetchPushCommitCount(ref) + } + }), + ) + + return result + } + + private async fetchPushCommitCount(ref: PushCommitCountRef): Promise { + const [owner, repo] = ref.repoFullName.split('/') + try { + const response = await this.octokit.rest.repos.compareCommitsWithBasehead({ + owner, + repo, + basehead: `${ref.before}...${ref.head}`, + per_page: 1, + }) + return typeof response.data.total_commits === 'number' ? response.data.total_commits : null + } catch { + // Force-push / rewritten or deleted history / lost access: count stays unknown. + return null + } + } +} + +export interface PushCommitCountRef { + key: string + repoFullName: string + before: string + head: string +} + +const PUSH_COUNT_CONCURRENCY = 8 +const ZERO_SHA = '0000000000000000000000000000000000000000' + +function isEnrichablePushRef(ref: PushCommitCountRef): boolean { + return ( + isValidRepoFullName(ref.repoFullName) + && ref.before.length > 0 + && ref.head.length > 0 + && ref.before !== ZERO_SHA + ) } const REPO_CARDS_CHUNK_SIZE = 50 @@ -183,7 +250,15 @@ function normalizeFeedEventPayload( return { kind: 'push', branch: String(payload.ref ?? '').replace(/^refs\/heads\//, ''), - commitCount: typeof payload.size === 'number' ? payload.size : (payload.commits?.length ?? 0), + beforeSha: String(payload.before ?? ''), + headSha: String(payload.head ?? ''), + // GitHub reduced the PushEvent payload: `size`/`distinct_size`/`commits` are no + // longer sent, so the count is unknown here. `null` signals "resolve it later" + // (via getPushCommitCounts) rather than a fake `0`; keep reading `size` for the + // rare payloads / future restores that still carry it. + commitCount: typeof payload.size === 'number' + ? payload.size + : (Array.isArray(payload.commits) && payload.commits.length > 0 ? payload.commits.length : null), commitMessages: (Array.isArray(payload.commits) ? payload.commits : []) .slice(0, MAX_COMMIT_MESSAGES) .map((commit: { message?: string | null }) => firstLine(commit.message)) diff --git a/packages/client/src/main/activity.ts b/packages/client/src/main/activity.ts index b6aa99f..cc7f326 100644 --- a/packages/client/src/main/activity.ts +++ b/packages/client/src/main/activity.ts @@ -1,4 +1,4 @@ -import { createGitHubApi } from '@oh-my-github/api' +import { createGitHubApi, type PushCommitCountRef } from '@oh-my-github/api' import { ipcMain } from 'electron' import { getAuthenticatedAccessToken, getAuthenticatedViewerLogin } from './auth' import { resolveGitHubProxyUrl } from './proxy' @@ -14,6 +14,9 @@ export function registerActivityIpc(): void { ipcMain.handle('activity:get-repository-cards', (_event, fullNames: string[]) => getRepositoryCards(fullNames), ) + ipcMain.handle('activity:get-push-commit-counts', (_event, refs: PushCommitCountRef[]) => + getPushCommitCounts(refs), + ) } async function getRepositoryCards(fullNames: string[]) { @@ -21,6 +24,11 @@ async function getRepositoryCards(fullNames: string[]) { return api.activity.getRepositoryCards(Array.isArray(fullNames) ? fullNames : []) } +async function getPushCommitCounts(refs: PushCommitCountRef[]) { + const api = await createAuthenticatedGitHubApi() + return api.activity.getPushCommitCounts(Array.isArray(refs) ? refs : []) +} + async function listReceivedEvents(options?: ListReceivedEventsIpcOptions) { const api = await createAuthenticatedGitHubApi() return api.activity.listReceivedEvents({ diff --git a/packages/client/src/main/index.ts b/packages/client/src/main/index.ts index 3523810..7c16d0c 100644 --- a/packages/client/src/main/index.ts +++ b/packages/client/src/main/index.ts @@ -183,70 +183,87 @@ function sendToRenderer(channel: string, payload?: unknown): void { } } -void app.whenReady().then(() => { - app.setAppUserModelId('dev.oh-my-github.client') - configureApplicationMenu() - configureDevelopmentAppIcon() - registerAccountsIpc() - registerActionsIpc() - registerActivityIpc() - registerAuthIpc() - registerBookmarksIpc() - registerConfigIpc((config) => { - applyThemeSource(config.ui.theme) - appTray?.refresh() +// Single-instance guard. The app keeps running in the tray, so relaunching the +// binary (double-clicking the desktop icon) would otherwise spawn a second process +// with its own window AND its own tray icon. Hold a lock: a duplicate launch quits +// immediately and the primary instance surfaces its existing window instead. +const gotSingleInstanceLock = app.requestSingleInstanceLock() + +if (!gotSingleInstanceLock) { + // A primary instance already owns the app (possibly hidden in the tray). Quit this + // duplicate before it registers any window / tray / IPC, and let the primary surface + // its window via 'second-instance' — the shape of Electron's single-instance example. + app.quit() +} else { + app.on('second-instance', () => { + showWindow() }) - registerDeploymentsIpc() - registerInboxIpc() - registerIssuesIpc() - registerLinksIpc() - registerOrganizationPeopleIpc() - registerPackagesIpc() - registerPinsIpc() - registerPullsIpc() - registerReleasesIpc() - registerRepositoriesIpc() - registerRepositorySettingsIpc() - registerSearchIpc() - registerUserSettingsIpc() - registerUpdatesIpc() - registerWindowIpc() - initializeAuth() - applyThemeSource(initializeConfig().config.ui.theme) - createWindow() - appTray = createAppTray({ - showWindow, - sendToRenderer, - getLanguage: () => getLocalConfig().ui.locale, - isAuthenticated, - listBookmarks: () => readBookmarks(), - listNotifications: (limit) => listRecentNotifications(limit), - onAuthChanged, - quit: () => { - isQuitting = true - app.quit() - } + + void app.whenReady().then(() => { + app.setAppUserModelId('dev.oh-my-github.client') + configureApplicationMenu() + configureDevelopmentAppIcon() + registerAccountsIpc() + registerActionsIpc() + registerActivityIpc() + registerAuthIpc() + registerBookmarksIpc() + registerConfigIpc((config) => { + applyThemeSource(config.ui.theme) + appTray?.refresh() + }) + registerDeploymentsIpc() + registerInboxIpc() + registerIssuesIpc() + registerLinksIpc() + registerOrganizationPeopleIpc() + registerPackagesIpc() + registerPinsIpc() + registerPullsIpc() + registerReleasesIpc() + registerRepositoriesIpc() + registerRepositorySettingsIpc() + registerSearchIpc() + registerUserSettingsIpc() + registerUpdatesIpc() + registerWindowIpc() + initializeAuth() + applyThemeSource(initializeConfig().config.ui.theme) + createWindow() + appTray = createAppTray({ + showWindow, + sendToRenderer, + getLanguage: () => getLocalConfig().ui.locale, + isAuthenticated, + listBookmarks: () => readBookmarks(), + listNotifications: (limit) => listRecentNotifications(limit), + onAuthChanged, + quit: () => { + isQuitting = true + app.quit() + } + }) + + app.on('activate', () => { + showWindow() + }) }) - app.on('activate', () => { - showWindow() + app.on('before-quit', () => { + isQuitting = true + }) + + // macOS installs updates through Electron's native autoUpdater, which closes all + // windows WITHOUT emitting 'before-quit' (it emits 'before-quit-for-update' on + // itself instead) and only installs/relaunches once 'window-all-closed' fires. + // Without this, the close-to-tray guard hides the window, the updater waits + // forever, and "Restart to update" leaves the old app running hidden in the tray. + autoUpdater.on('before-quit-for-update', () => { + isQuitting = true }) -}) - -app.on('before-quit', () => { - isQuitting = true -}) - -// macOS installs updates through Electron's native autoUpdater, which closes all -// windows WITHOUT emitting 'before-quit' (it emits 'before-quit-for-update' on -// itself instead) and only installs/relaunches once 'window-all-closed' fires. -// Without this, the close-to-tray guard hides the window, the updater waits -// forever, and "Restart to update" leaves the old app running hidden in the tray. -autoUpdater.on('before-quit-for-update', () => { - isQuitting = true -}) - -app.on('window-all-closed', () => { - // The app keeps running in the tray on all platforms. Quit happens only via the - // tray's Quit item (which sets isQuitting) or a genuine OS/updater quit. -}) + + app.on('window-all-closed', () => { + // The app keeps running in the tray on all platforms. Quit happens only via the + // tray's Quit item (which sets isQuitting) or a genuine OS/updater quit. + }) +} diff --git a/packages/client/src/main/tray.ts b/packages/client/src/main/tray.ts index ac2b953..bb2882f 100644 --- a/packages/client/src/main/tray.ts +++ b/packages/client/src/main/tray.ts @@ -41,6 +41,10 @@ export function createAppTray(deps: AppTrayDeps): AppTrayHandle { const tray = new Tray(resolveTrayIcon()) tray.setToolTip('Oh My GitHub') + // Double-clicking the tray icon restores/focuses the window (creating it if the app + // was closed to tray). Right-click still opens the context menu. + tray.on('double-click', () => deps.showWindow()) + let notifications: GitHubNotification[] = [] const handlers: TrayMenuHandlers = { diff --git a/packages/client/src/preload/index.ts b/packages/client/src/preload/index.ts index 4deccf6..e8f666b 100644 --- a/packages/client/src/preload/index.ts +++ b/packages/client/src/preload/index.ts @@ -156,6 +156,9 @@ const api = { ipcRenderer.invoke('activity:list-received-events', options), getRepositoryCards: (fullNames: string[]) => ipcRenderer.invoke('activity:get-repository-cards', fullNames), + getPushCommitCounts: ( + refs: Array<{ key: string, repoFullName: string, before: string, head: string }>, + ) => ipcRenderer.invoke('activity:get-push-commit-counts', refs), }, releases: { listRepositoryReleases: (options: unknown) => diff --git a/packages/client/src/renderer/composables/github/use-activity.ts b/packages/client/src/renderer/composables/github/use-activity.ts index 09c2846..f310fcb 100644 --- a/packages/client/src/renderer/composables/github/use-activity.ts +++ b/packages/client/src/renderer/composables/github/use-activity.ts @@ -30,3 +30,9 @@ export async function fetchActivityRepoCards( ): Promise> { return requireActivityBridge().getRepositoryCards(fullNames) } + +export async function fetchActivityPushCounts( + refs: Array<{ key: string, repoFullName: string, before: string, head: string }>, +): Promise> { + return requireActivityBridge().getPushCommitCounts(refs) +} diff --git a/packages/client/src/renderer/env.d.ts b/packages/client/src/renderer/env.d.ts index a28b86b..7dcf5df 100644 --- a/packages/client/src/renderer/env.d.ts +++ b/packages/client/src/renderer/env.d.ts @@ -1565,7 +1565,7 @@ type GitHubFeedEventPayload = | { kind: 'fork'; forkFullName: string | null } | { kind: 'create'; refType: 'repository' | 'branch' | 'tag'; ref: string | null } | { kind: 'delete'; refType: 'branch' | 'tag'; ref: string } - | { kind: 'push'; branch: string; commitCount: number; commitMessages: string[] } + | { kind: 'push'; branch: string; beforeSha: string; headSha: string; commitCount: number | null; commitMessages: string[] } | { kind: 'release'; tagName: string; releaseName: string | null; excerpt: string | null } | { kind: 'public' } | { kind: 'member'; memberLogin: string | null } @@ -2291,6 +2291,9 @@ interface Window { activity: { listReceivedEvents: (options?: { page?: number }) => Promise getRepositoryCards: (fullNames: string[]) => Promise> + getPushCommitCounts: ( + refs: Array<{ key: string; repoFullName: string; before: string; head: string }>, + ) => Promise> } releases: { listRepositoryReleases: (options: ListRepositoryReleasesOptions) => Promise diff --git a/packages/client/src/renderer/i18n/locales/en.json b/packages/client/src/renderer/i18n/locales/en.json index 8cb9a0e..73bd272 100644 --- a/packages/client/src/renderer/i18n/locales/en.json +++ b/packages/client/src/renderer/i18n/locales/en.json @@ -2585,6 +2585,7 @@ "deletedBranch": "deleted branch {ref} in {repo}", "deletedTag": "deleted tag {ref} in {repo}", "pushed": "pushed {count} commit to {repo} · {branch} | pushed {count} commits to {repo} · {branch}", + "pushedUnknown": "pushed to {repo} · {branch}", "published": "published {release} in {repo}", "madePublic": "made {repo} public", "addedMember": "added {member} to {repo}", diff --git a/packages/client/src/renderer/i18n/locales/zh.json b/packages/client/src/renderer/i18n/locales/zh.json index 0632661..dfa500b 100644 --- a/packages/client/src/renderer/i18n/locales/zh.json +++ b/packages/client/src/renderer/i18n/locales/zh.json @@ -2585,6 +2585,7 @@ "deletedBranch": "删除了 {repo} 的分支 {ref}", "deletedTag": "删除了 {repo} 的标签 {ref}", "pushed": "向 {repo} · {branch} 推送了 {count} 个提交", + "pushedUnknown": "推送到 {repo} · {branch}", "published": "在 {repo} 发布了 {release}", "madePublic": "公开了 {repo}", "addedMember": "邀请 {member} 加入 {repo}", diff --git a/packages/client/src/renderer/pages/activity/activity-helpers.test.ts b/packages/client/src/renderer/pages/activity/activity-helpers.test.ts index 0055d7b..eb9aa99 100644 --- a/packages/client/src/renderer/pages/activity/activity-helpers.test.ts +++ b/packages/client/src/renderer/pages/activity/activity-helpers.test.ts @@ -1,12 +1,14 @@ import { describe, expect, it } from 'vitest' import { ACTIVITY_FILTER_KEYS, + collectPushCountRefs, collectRepoCardNames, groupFeedEvents, matchesActivityFilter, mergeFeedEvents, presentFeedEvent, presentFeedGroup, + pushCountRefForGroup, } from './activity-helpers' let nextId = 1 @@ -64,7 +66,7 @@ describe('groupFeedEvents', () => { it('sums commit counts and merges commit messages for adjacent pushes', () => { const push = (count: number, messages: string[]) => feedEvent({ - payload: { kind: 'push', branch: 'main', commitCount: count, commitMessages: messages }, + payload: { kind: 'push', branch: 'main', beforeSha: 'a1', headSha: 'b2', commitCount: count, commitMessages: messages }, actorLogin: 'posva', repoFullName: 'vuejs/pinia', }) @@ -115,7 +117,7 @@ describe('presentFeedEvent', () => { it('targets the commits section for pushes with a commits card', () => { const event = feedEvent({ - payload: { kind: 'push', branch: 'main', commitCount: 3, commitMessages: ['fix: a'] }, + payload: { kind: 'push', branch: 'main', beforeSha: 'a1', headSha: 'b2', commitCount: 3, commitMessages: ['fix: a'] }, }) const presentation = presentFeedEvent(event) @@ -125,6 +127,28 @@ describe('presentFeedEvent', () => { expect(presentation.card).toEqual({ kind: 'commits', messages: ['fix: a'], url: '/vitejs/vite?tab=commits' }) }) + it('omits the count for a push whose commit count is unknown', () => { + const event = feedEvent({ + payload: { kind: 'push', branch: 'main', beforeSha: 'a1', headSha: 'b2', commitCount: null, commitMessages: [] }, + }) + const presentation = presentFeedEvent(event) + + expect(presentation.sentenceKey).toBe('workspace.activity.sentences.pushedUnknown') + expect(presentation.pluralCount).toBeNull() + expect(presentation.parts.count).toBeUndefined() + }) + + it('prefers the resolved compare count over the payload count', () => { + const event = feedEvent({ + payload: { kind: 'push', branch: 'main', beforeSha: 'a1', headSha: 'b2', commitCount: null, commitMessages: [] }, + }) + const presentation = presentFeedEvent(event, 7) + + expect(presentation.sentenceKey).toBe('workspace.activity.sentences.pushed') + expect(presentation.pluralCount).toBe(7) + expect(presentation.parts.count.label).toBe('7') + }) + it('links merged pull requests to the PR tab with a text card', () => { const event = feedEvent({ payload: { kind: 'pull-request', action: 'closed', number: 9, title: 'Add feed', merged: true, excerpt: 'Adds the feed.' }, @@ -177,7 +201,7 @@ describe('collectRepoCardNames', () => { star('antfu', 'a/a'), star('posva', 'a/a'), feedEvent({ payload: { kind: 'fork', forkFullName: 'antfu/vite' } }), - feedEvent({ payload: { kind: 'push', branch: 'main', commitCount: 1, commitMessages: [] } }), + feedEvent({ payload: { kind: 'push', branch: 'main', beforeSha: 'a1', headSha: 'b2', commitCount: 1, commitMessages: [] } }), ] expect(collectRepoCardNames(events).sort()).toEqual(['a/a', 'antfu/vite']) }) @@ -194,4 +218,34 @@ describe('presentFeedGroup', () => { expect(presentation.children.map((child) => child.part.label)).toEqual(['a/a', 'b/b']) expect(presentation.children[0].part.url).toBe('/a/a') }) + + it('uses the resolved compare total for a push group', () => { + const push = (before: string, head: string) => + feedEvent({ + payload: { kind: 'push', branch: 'main', beforeSha: before, headSha: head, commitCount: null, commitMessages: [] }, + actorLogin: 'posva', + repoFullName: 'vuejs/pinia', + }) + const groups = groupFeedEvents([push('c3', 'd4'), push('a1', 'c3')]) + + expect(presentFeedGroup(groups[0]).sentenceKey).toBe('workspace.activity.sentences.pushedUnknown') + expect(presentFeedGroup(groups[0], 9).pluralCount).toBe(9) + }) +}) + +describe('push count refs', () => { + it('spans the oldest before SHA to the newest head SHA of a group', () => { + const push = (before: string, head: string) => + feedEvent({ + payload: { kind: 'push', branch: 'main', beforeSha: before, headSha: head, commitCount: null, commitMessages: [] }, + actorLogin: 'posva', + repoFullName: 'vuejs/pinia', + }) + // Newest-first ordering: the group's compare range is oldest.before...newest.head. + const groups = groupFeedEvents([push('c3', 'd4'), push('a1', 'c3')]) + const ref = pushCountRefForGroup(groups[0]) + + expect(ref).toEqual({ key: 'vuejs/pinia@a1...d4', repoFullName: 'vuejs/pinia', before: 'a1', head: 'd4' }) + expect(collectPushCountRefs(groups).map((entry) => entry.key)).toEqual(['vuejs/pinia@a1...d4']) + }) }) diff --git a/packages/client/src/renderer/pages/activity/activity-helpers.ts b/packages/client/src/renderer/pages/activity/activity-helpers.ts index a15d885..8112e90 100644 --- a/packages/client/src/renderer/pages/activity/activity-helpers.ts +++ b/packages/client/src/renderer/pages/activity/activity-helpers.ts @@ -132,9 +132,56 @@ export function collectRepoCardNames(events: GitHubFeedEvent[]): string[] { return [...names] } +export interface ActivityPushCountRef { + key: string + repoFullName: string + before: string + head: string +} + +export function buildPushCountKey(repoFullName: string, before: string, head: string): string { + return `${repoFullName}@${before}...${head}` +} + +function makePushCountRef(repoFullName: string, before: string, head: string): ActivityPushCountRef | null { + if (!before || !head) return null + return { key: buildPushCountKey(repoFullName, before, head), repoFullName, before, head } +} + +export function pushCountRefForEvent(event: GitHubFeedEvent): ActivityPushCountRef | null { + if (event.payload.kind !== 'push') return null + return makePushCountRef(event.repoFullName, event.payload.beforeSha, event.payload.headSha) +} + +export function pushCountRefForGroup(group: ActivityFeedGroup): ActivityPushCountRef | null { + const newest = group.events[0] + const oldest = group.events[group.events.length - 1] + if (newest?.payload.kind !== 'push' || oldest?.payload.kind !== 'push') return null + // Events are newest-first, so the compare range spans the oldest push's `before` to the + // newest push's `head` — the group's whole pushed span in one call. + return makePushCountRef(newest.repoFullName, oldest.payload.beforeSha, newest.payload.headSha) +} + +// Collect the unique compare refs to enrich for the currently-grouped feed. Covers both +// single push rows and multi-push groups (a single is a group of one). +export function collectPushCountRefs(groups: ActivityFeedGroup[]): ActivityPushCountRef[] { + const refs = new Map() + for (const group of groups) { + const ref = pushCountRefForGroup(group) + if (ref) refs.set(ref.key, ref) + } + return [...refs.values()] +} + const SENTENCE_PREFIX = 'workspace.activity.sentences' -export function presentFeedEvent(event: GitHubFeedEvent): FeedEventPresentation { +export function presentFeedEvent( + event: GitHubFeedEvent, + // Real commit count for a push, resolved via the compare API (GitHub no longer sends + // it in the payload). `undefined` = not looked up (use the payload's own value if any); + // `null` = looked up but unavailable → render the count-less sentence, never a fake 0. + resolvedPushCount?: number | null, +): FeedEventPresentation { const { payload } = event const { owner, repo } = splitRepoFullName(event.repoFullName) const repoUrl = owner && repo ? createRepositoryWorkspaceUrl(owner, repo) : null @@ -180,17 +227,29 @@ export function presentFeedEvent(event: GitHubFeedEvent): FeedEventPresentation } case 'push': { const commitsUrl = owner && repo ? createRepositoryWorkspaceUrl(owner, repo, 'commits') : null + const count = resolvedPushCount === undefined ? payload.commitCount : resolvedPushCount + const branchPart: FeedSentencePart = { label: payload.branch, url: commitsUrl } + const pushCard: FeedEventCard | null = payload.commitMessages.length + ? { kind: 'commits', messages: payload.commitMessages, url: commitsUrl ?? repoUrl } + : null + if (count === null) { + return { + ...base, + sentenceKey: `${SENTENCE_PREFIX}.pushedUnknown`, + parts: { repo: repoPart, branch: branchPart }, + card: pushCard, + targetUrl: commitsUrl ?? repoUrl, + } + } return { sentenceKey: `${SENTENCE_PREFIX}.pushed`, - pluralCount: payload.commitCount, + pluralCount: count, parts: { repo: repoPart, - branch: { label: payload.branch, url: commitsUrl }, - count: { label: String(payload.commitCount), url: null }, + branch: branchPart, + count: { label: String(count), url: null }, }, - card: payload.commitMessages.length - ? { kind: 'commits', messages: payload.commitMessages, url: commitsUrl ?? repoUrl } - : null, + card: pushCard, targetUrl: commitsUrl ?? repoUrl, } } @@ -320,22 +379,41 @@ export interface FeedGroupPresentation { children: Array<{ id: string; part: FeedSentencePart; createdAt: string }> } -export function presentFeedGroup(group: ActivityFeedGroup): FeedGroupPresentation { +export function presentFeedGroup( + group: ActivityFeedGroup, + // Aggregate commit count for the whole push group, resolved via one compare call over + // the group's oldest→newest SHA range. Same semantics as presentFeedEvent's parameter. + resolvedPushCount?: number | null, +): FeedGroupPresentation { if (group.kind === 'push') { const first = presentFeedEvent(group.events[0]) - const total = group.events.reduce( - (sum, event) => sum + (event.payload.kind === 'push' ? event.payload.commitCount : 0), - 0, - ) + const payloadTotal = group.events.reduce((sum, event) => { + if (event.payload.kind !== 'push' || typeof event.payload.commitCount !== 'number') return sum + return (sum ?? 0) + event.payload.commitCount + }, null) + const total = resolvedPushCount === undefined ? payloadTotal : resolvedPushCount const messages = group.events .flatMap((event) => (event.payload.kind === 'push' ? event.payload.commitMessages : [])) .slice(0, 5) + const card: FeedEventCard | null = messages.length ? { kind: 'commits', messages, url: first.targetUrl } : null + + if (total === null) { + return { + sentenceKey: `${SENTENCE_PREFIX}.pushedUnknown`, + pluralCount: null, + parts: { repo: first.parts.repo, branch: first.parts.branch }, + card, + targetUrl: first.targetUrl, + expandable: false, + children: [], + } + } return { sentenceKey: `${SENTENCE_PREFIX}.pushed`, pluralCount: total, - parts: { ...first.parts, count: { label: String(total), url: null } }, - card: messages.length ? { kind: 'commits', messages, url: first.targetUrl } : null, + parts: { repo: first.parts.repo, branch: first.parts.branch, count: { label: String(total), url: null } }, + card, targetUrl: first.targetUrl, expandable: false, children: [], diff --git a/packages/client/src/renderer/pages/activity/activity-page.vue b/packages/client/src/renderer/pages/activity/activity-page.vue index 141e56e..f407880 100644 --- a/packages/client/src/renderer/pages/activity/activity-page.vue +++ b/packages/client/src/renderer/pages/activity/activity-page.vue @@ -5,10 +5,11 @@ import { computed, reactive, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { Badge, Button, Empty, EmptyDescription, EmptyHeader, EmptyTitle, ScrollArea, Skeleton } from '@oh-my-github/ui' import { Activity as ActivityIcon } from 'lucide-vue-next' -import { fetchActivityFeedPage, fetchActivityRepoCards, useActivityFeedQuery } from '@/composables/github/use-activity' +import { fetchActivityFeedPage, fetchActivityPushCounts, fetchActivityRepoCards, useActivityFeedQuery } from '@/composables/github/use-activity' import { useToast } from '@/composables/use-toast' import { ACTIVITY_FILTER_KEYS, + collectPushCountRefs, collectRepoCardNames, groupFeedEvents, matchesActivityFilter, @@ -64,6 +65,27 @@ watch(events, async (list) => { } }, { immediate: true }) +// PushEvent payloads no longer carry commit counts; resolve them per push group via the +// compare API as progressive enhancement, mirroring the repo-card enrichment above. +const pushCounts = reactive(new Map()) +const pendingPushKeys = new Set() + +watch(groups, async (list) => { + const missing = collectPushCountRefs(list) + .filter((ref) => !pushCounts.has(ref.key) && !pendingPushKeys.has(ref.key)) + if (missing.length === 0) return + + for (const ref of missing) pendingPushKeys.add(ref.key) + try { + const counts = await fetchActivityPushCounts(missing) + for (const ref of missing) pushCounts.set(ref.key, counts[ref.key] ?? null) + } catch { + // 静默降级:无法解析的推送保持无计数文案,下次分组变化会重试缺失项 + } finally { + for (const ref of missing) pendingPushKeys.delete(ref.key) + } +}, { immediate: true }) + function toggleFilter(key: ActivityFilterKey): void { filter.value = filter.value === key ? null : key } @@ -160,11 +182,13 @@ async function loadMore(): Promise { v-if="group.kind === 'single'" :event="group.events[0]" :repo-cards="repoCards" + :push-counts="pushCounts" /> diff --git a/packages/client/src/renderer/pages/activity/components/activity-event-row.vue b/packages/client/src/renderer/pages/activity/components/activity-event-row.vue index cbff90f..253d660 100644 --- a/packages/client/src/renderer/pages/activity/components/activity-event-row.vue +++ b/packages/client/src/renderer/pages/activity/components/activity-event-row.vue @@ -4,18 +4,28 @@ import { useI18n } from 'vue-i18n' import { useRouter } from 'vue-router' import GithubActorLink from '@/components/github/github-actor-link.vue' import { formatRelativeTime } from '@/components/conversation/format' -import { presentFeedEvent } from '../activity-helpers' +import { presentFeedEvent, pushCountRefForEvent } from '../activity-helpers' import ActivityFeedCard from './activity-feed-card.vue' const props = defineProps<{ event: GitHubFeedEvent repoCards?: Map + pushCounts?: Map }>() const { locale } = useI18n() const router = useRouter() -const presentation = computed(() => presentFeedEvent(props.event)) +const presentation = computed(() => { + const ref = pushCountRefForEvent(props.event) + // Only override the payload count once the compare result is actually cached: a Map + // miss (still pending / non-push) stays `undefined` so presentFeedEvent falls back to + // the payload's own count; a cached `null` (unavailable) renders the count-less sentence. + const resolved = ref && props.pushCounts?.has(ref.key) + ? props.pushCounts.get(ref.key)! + : undefined + return presentFeedEvent(props.event, resolved) +}) const relativeTime = computed(() => formatRelativeTime(props.event.createdAt, { locale: locale.value })) function openTarget(): void { diff --git a/packages/client/src/renderer/pages/activity/components/activity-group-row.vue b/packages/client/src/renderer/pages/activity/components/activity-group-row.vue index f53c874..c966cef 100644 --- a/packages/client/src/renderer/pages/activity/components/activity-group-row.vue +++ b/packages/client/src/renderer/pages/activity/components/activity-group-row.vue @@ -6,19 +6,28 @@ import { useRouter } from 'vue-router' import { ChevronDown, ChevronRight } from 'lucide-vue-next' import GithubActorLink from '@/components/github/github-actor-link.vue' import { formatRelativeTime } from '@/components/conversation/format' -import { presentFeedGroup } from '../activity-helpers' +import { presentFeedGroup, pushCountRefForGroup } from '../activity-helpers' import ActivityFeedCard from './activity-feed-card.vue' const props = defineProps<{ group: ActivityFeedGroup repoCards?: Map + pushCounts?: Map }>() const { locale } = useI18n() const router = useRouter() const expanded = ref(false) -const presentation = computed(() => presentFeedGroup(props.group)) +const presentation = computed(() => { + const ref = pushCountRefForGroup(props.group) + // A Map miss (pending / non-push) stays `undefined` so presentFeedGroup falls back to + // the group's own payloadTotal; only a cached `null` renders the count-less sentence. + const resolved = ref && props.pushCounts?.has(ref.key) + ? props.pushCounts.get(ref.key)! + : undefined + return presentFeedGroup(props.group, resolved) +}) const relativeTime = computed(() => formatRelativeTime(props.group.createdAt, { locale: locale.value })) function onRowClick(): void {