Skip to content
Merged
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
69 changes: 69 additions & 0 deletions packages/api/src/modules/activity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,55 @@ describe('ActivityApi.listReceivedEvents', () => {
})
})

describe('ActivityApi.getPushCommitCounts', () => {
function createCompareApi(totalByBasehead: Record<string, number | null | Error>) {
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({
Expand All @@ -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' },
Expand All @@ -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' })
Expand Down
79 changes: 77 additions & 2 deletions packages/api/src/modules/activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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<Record<string, number | null>> {
const result: Record<string, number | null> = {}
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<number | null> {
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
Expand Down Expand Up @@ -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))
Expand Down
10 changes: 9 additions & 1 deletion packages/client/src/main/activity.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -14,13 +14,21 @@ 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[]) {
const api = await createAuthenticatedGitHubApi()
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({
Expand Down
143 changes: 80 additions & 63 deletions packages/client/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

建议把后续 app.whenReady().then(...) 初始化整个放进 else,或在 duplicate instance 分支直接 early-return/exit。当前调用 app.quit() 后,模块下面仍会注册 whenReady 初始化回调;大多时候进程会退出,但这个结构让副进程的 IPC/window/tray 初始化路径仍可被排队,读起来也偏离 Electron single-instance 官方示例。

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已重构 (cf0dfbd):把 whenReady 及 before-quit / updater / window-all-closed 全部放进单实例锁的 else,副实例 app.quit() 后不再排队任何 window/tray/IPC 初始化,贴合 Electron 官方示例结构。

} 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.
})
}
4 changes: 4 additions & 0 deletions packages/client/src/main/tray.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
3 changes: 3 additions & 0 deletions packages/client/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
Loading
Loading