Add perf prerelease update check modifier

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-07-03 20:25:55 -04:00
co-authored by Orca
parent 4c78440f70
commit 821c8b69fb
14 changed files with 404 additions and 58 deletions
+31 -8
View File
@@ -139,7 +139,7 @@ describe('registerAppMenu', () => {
expect(options.onBeforeReload).toHaveBeenCalledWith({ ignoreCache: true, webContentsId: 102 })
})
it('includes prereleases when Check for Updates is clicked with shift held', () => {
it('routes Check for Updates modifier clicks to prerelease and perf checks', () => {
const options = buildMenuOptions()
registerAppMenu(options)
@@ -152,19 +152,42 @@ describe('registerAppMenu', () => {
)
item?.click?.({} as never, undefined as never, { shiftKey: true } as Electron.KeyboardEvent)
item?.click?.({} as never, undefined as never, {} as Electron.KeyboardEvent)
item?.click?.(
{} as never,
undefined as never,
{ metaKey: true, shiftKey: true } as Electron.KeyboardEvent
{
shiftKey: true,
...(isMac ? { metaKey: true } : { ctrlKey: true })
} as Electron.KeyboardEvent
)
item?.click?.(
{} as never,
undefined as never,
(isMac ? { metaKey: true } : { ctrlKey: true }) as Electron.KeyboardEvent
)
item?.click?.(
{} as never,
undefined as never,
(isMac ? { ctrlKey: true } : { metaKey: true }) as Electron.KeyboardEvent
)
item?.click?.(
{} as never,
undefined as never,
{
triggeredByAccelerator: true,
shiftKey: true,
...(isMac ? { metaKey: true } : { ctrlKey: true })
} as Electron.KeyboardEvent
)
item?.click?.({} as never, undefined as never, {} as Electron.KeyboardEvent)
item?.click?.({} as never, undefined as never, { metaKey: true } as Electron.KeyboardEvent)
expect(options.onCheckForUpdates.mock.calls).toEqual([
[{ includePrerelease: true }],
[{ includePrerelease: true }],
[{ includePrerelease: false }],
[{ includePrerelease: false }]
[{ includePrerelease: true, includePerfPrerelease: false }],
[{ includePrerelease: false, includePerfPrerelease: false }],
[{ includePrerelease: true, includePerfPrerelease: true }],
[{ includePrerelease: false, includePerfPrerelease: true }],
[{ includePrerelease: false, includePerfPrerelease: false }],
[{ includePrerelease: false, includePerfPrerelease: false }]
])
})
+10 -6
View File
@@ -5,6 +5,7 @@ import {
type KeybindingActionId,
type KeybindingOverrides
} from '../../shared/keybindings'
import type { UpdateCheckOptions } from '../../shared/types'
import { translateMain } from '../i18n/main-i18n'
export type AppearanceMenuState = {
@@ -26,7 +27,7 @@ type RegisterAppMenuOptions = {
onOpenSetupGuide: (window?: Electron.BaseWindow | null) => void
onOpenFeatureTour: (window?: Electron.BaseWindow | null) => void
onOpenCrashReport: (window?: Electron.BaseWindow | null) => void
onCheckForUpdates: (options: { includePrerelease: boolean }) => void
onCheckForUpdates: (options: UpdateCheckOptions) => void
onBeforeReload?: (options: { ignoreCache: boolean; webContentsId: number }) => void
onZoomIn: () => void
onZoomOut: () => void
@@ -83,16 +84,19 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void {
webContents.reload()
}
// Why: holding Shift while clicking Check for Updates opts this check into
// the release-candidate channel. Extracted so both the macOS app-menu entry
// and the Windows/Linux Help-menu entry share the exact same behavior.
// Why: modifier-click update checks are hidden power-user affordances.
// Extracted so the macOS app-menu entry and Windows/Linux Help entry share
// identical RC/perf channel routing.
const checkForUpdatesClick: Electron.MenuItemConstructorOptions['click'] = (
_menuItem,
_window,
event
) => {
const includePrerelease = !event.triggeredByAccelerator && event.shiftKey === true
onCheckForUpdates({ includePrerelease })
const modifierClick = !event.triggeredByAccelerator
const includePerfPrerelease =
modifierClick && (isMac ? event.metaKey === true : event.ctrlKey === true)
const includePrerelease = modifierClick && event.shiftKey === true
onCheckForUpdates({ includePrerelease, includePerfPrerelease })
}
const checkForUpdatesItem: Electron.MenuItemConstructorOptions = {
+57
View File
@@ -187,6 +187,63 @@ describe('fetchNewerReleaseTag', () => {
expect(await fetchNewerReleaseTag('1.3.19-rc.6')).toBe('v1.3.20-rc.1')
})
it('picks the semver-newest perf-tagged prerelease', async () => {
respondWithAtom([
'v1.4.121-rc.6.perf',
'v1.4.121-rc.7',
'v1.4.121-rc.5.perf',
'v1.4.121-rc.6',
'v1.4.122'
])
const { fetchNewerReleaseTag } = await import('./updater-prerelease-feed')
expect(
await fetchNewerReleaseTag('1.4.120', {
includePrerelease: true,
releaseFilter: 'perf'
})
).toBe('v1.4.121-rc.6.perf')
})
it('matches only literal rc.N.perf prerelease tags for perf checks', async () => {
respondWithAtom([
'v1.4.121-rc.7.performance',
'v1.4.121-beta.7.perf',
'v1.4.121-rc.7.perf.extra',
'v1.4.121-rc.6.perf'
])
const { fetchNewerReleaseTag, isPerfPrereleaseTag } = await import('./updater-prerelease-feed')
expect(isPerfPrereleaseTag('v1.4.121-rc.6.perf')).toBe(true)
expect(isPerfPrereleaseTag('v1.4.121-rc.7.performance')).toBe(false)
expect(isPerfPrereleaseTag('v1.4.121-beta.7.perf')).toBe(false)
expect(isPerfPrereleaseTag('v1.4.121-rc.7.perf.extra')).toBe(false)
expect(
await fetchNewerReleaseTag('1.4.120', {
includePrerelease: true,
releaseFilter: 'perf'
})
).toBe('v1.4.121-rc.6.perf')
})
it('reports no perf update instead of falling back to stable or RC tags', async () => {
respondWithAtom(['v1.4.122', 'v1.4.121-rc.7', 'v1.4.121'])
const { fetchNewerReleaseTagsWithReadiness } = await import('./updater-prerelease-feed')
await expect(
fetchNewerReleaseTagsWithReadiness('1.4.120', 1, {
includePrerelease: true,
releaseFilter: 'perf'
})
).resolves.toEqual({
tags: [],
state: 'no-newer'
})
})
it('returns a bounded fallback candidate after the newest newer tag', async () => {
respondWithAtom(['v1.3.51-rc.7', 'v1.3.51-rc.6', 'v1.3.51-rc.5'])
const { fetchNewerReleaseTags } = await import('./updater-prerelease-feed')
+19 -3
View File
@@ -43,6 +43,18 @@ type ReleaseFeedTag = {
version: string
}
export function isPerfPrereleaseTag(tag: string): boolean {
const version = normalizeTagToVersion(tag)
const match = version.match(/^\d+\.\d+\.\d+-([0-9A-Za-z-.]+)(?:\+[0-9A-Za-z-.]+)?$/)
const identifiers = match?.[1]?.split('.') ?? []
return (
identifiers.length === 3 &&
identifiers[0] === 'rc' &&
/^\d+$/.test(identifiers[1]) &&
identifiers[2] === 'perf'
)
}
async function fetchReleaseFeedTags(): Promise<ReleaseFeedTag[] | null> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
@@ -156,6 +168,7 @@ async function hasReadyPlatformManifest(tag: string): Promise<boolean> {
*/
type FetchNewerReleaseTagOptions = {
includePrerelease?: boolean
releaseFilter?: 'perf'
}
export type FetchNewerReleaseTagsResult = {
@@ -190,9 +203,12 @@ export async function fetchNewerReleaseTagsWithReadiness(
return { tags: [], state: 'unavailable' }
}
const candidates = includePrerelease
? tags
: tags.filter(({ version }) => !isPrereleaseVersion(version))
const candidates =
options.releaseFilter === 'perf'
? tags.filter(({ tag }) => isPerfPrereleaseTag(tag))
: includePrerelease
? tags
: tags.filter(({ version }) => !isPrereleaseVersion(version))
const newestNewerIndex = candidates.findIndex(
({ version }) => compareVersions(version, currentVersion) > 0
)
+54
View File
@@ -1012,6 +1012,60 @@ describe('updater', () => {
expect(autoUpdaterMock.setFeedURL.mock.calls.length).toBe(setupFeedUrlCalls + 1)
})
it('pins the generic feed to a perf-tagged prerelease when requested', async () => {
appMock.getVersion.mockReturnValue('1.4.120')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.4.121-rc.6.perf'])
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const mainWindow = { webContents: { send: vi.fn() } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu({ includePerfPrerelease: true })
await vi.waitFor(() => {
expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith('1.4.120', 2, {
includePrerelease: true,
releaseFilter: 'perf'
})
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/download/v1.4.121-rc.6.perf'
})
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
expect(autoUpdaterMock.allowPrerelease).toBe(true)
})
it('surfaces no-update feedback when no newer perf-tagged prerelease exists', async () => {
appMock.getVersion.mockReturnValue('1.4.120')
fetchNewerReleaseTagsMock.mockResolvedValue({ tags: [], state: 'no-newer' })
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
const setupFeedUrlCalls = autoUpdaterMock.setFeedURL.mock.calls.length
checkForUpdatesFromMenu({ includePerfPrerelease: true })
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'not-available',
userInitiated: true
})
})
expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith('1.4.120', 2, {
includePrerelease: true,
releaseFilter: 'perf'
})
expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled()
expect(autoUpdaterMock.setFeedURL.mock.calls.length).toBe(setupFeedUrlCalls)
})
it('leaves the feed URL alone for a normal user-initiated check', async () => {
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const mainWindow = { webContents: { send: vi.fn() } }
+85 -26
View File
@@ -2,7 +2,7 @@
import { app, BrowserWindow, powerMonitor } from 'electron'
import type { NsisUpdater } from 'electron-updater'
import { is } from '@electron-toolkit/utils'
import type { UpdateStatus } from '../shared/types'
import type { UpdateCheckOptions, UpdateStatus } from '../shared/types'
import { killAllPty } from './ipc/pty'
import { withUpdaterSpan } from './observability/instrumentation'
import { loadElectronAutoUpdater, type ElectronAutoUpdater } from './electron-updater-loader'
@@ -29,6 +29,8 @@ import { fetchNudge, shouldApplyNudge } from './updater-nudge'
type CheckFailureSource = 'event' | 'promise' | 'fallback-promise'
type MissingManifestPrereleaseFallbackResult = { userInitiated: boolean }
type PrimaryEventSuppression = { failureKey: string; error: unknown }
type UpdateCheckVariant = 'default' | 'prerelease' | 'perf'
type ReleaseFeedPreflightResult = 'ready' | 'not-available'
const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
const AUTO_UPDATE_RETRY_INTERVAL_MS = 60 * 60 * 1000
@@ -44,9 +46,9 @@ let currentStatus: UpdateStatus = { state: 'idle' }
let userInitiatedCheck = false
let onBeforeQuitCleanup: (() => void | Promise<void>) | null = null
let autoUpdaterInitialized = false
// Why: Shift-clicking "Check for Updates" opts the user into the RC release
// channel for the rest of this process. The generic feed still gets pinned to
// a concrete tag on every check so cancelled RCs without manifests are skipped.
// Why: modifier-clicking "Check for Updates" can target prerelease manifests.
// The generic feed still gets pinned to a concrete tag on every check so
// cancelled prereleases without manifests are skipped.
let includePrereleaseActive = false
let availableVersion: string | null = null
let availableReleaseUrl: string | null = null
@@ -69,7 +71,7 @@ let activeUpdateCheckAttemptId: number | null = null
let activeUpdateCheckLaunchAttemptId: number | null = null
let activeUpdateCheckEventAttemptId: number | null = null
let updateAvailableEventPendingAttemptId: number | null = null
let pendingPrereleaseUserInitiatedCheckAfterInFlight = false
let pendingUserInitiatedCheckAfterInFlight: UpdateCheckVariant | null = null
let activeUpdateNudgeId: string | null = null
let awaitingNudgeCheckOutcome = false
let nudgeCheckInFlight = false
@@ -156,8 +158,9 @@ function decorateStatusWithActiveNudge(status: UpdateStatus): UpdateStatus {
}
function sendStatus(status: UpdateStatus): void {
const shouldLaunchPendingPrereleaseCheck =
pendingPrereleaseUserInitiatedCheckAfterInFlight &&
const pendingUserInitiatedCheckVariant = pendingUserInitiatedCheckAfterInFlight
const shouldLaunchPendingUserInitiatedCheck =
pendingUserInitiatedCheckVariant !== null &&
(status.state === 'idle' ||
status.state === 'not-available' ||
status.state === 'available' ||
@@ -225,8 +228,8 @@ function sendStatus(status: UpdateStatus): void {
) {
downloadInFlight = false
}
if (shouldLaunchPendingPrereleaseCheck) {
launchPendingPrereleaseUserInitiatedCheckAfterInFlight()
if (shouldLaunchPendingUserInitiatedCheck) {
launchPendingUserInitiatedCheckAfterInFlight(pendingUserInitiatedCheckVariant)
return
}
if (statusesEqual(currentStatus, decoratedStatus)) {
@@ -236,16 +239,37 @@ function sendStatus(status: UpdateStatus): void {
mainWindowRef?.webContents.send('updater:status', decoratedStatus)
}
function launchPendingPrereleaseUserInitiatedCheckAfterInFlight(): void {
pendingPrereleaseUserInitiatedCheckAfterInFlight = false
function getOptionsForUpdateCheckVariant(variant: UpdateCheckVariant): UpdateCheckOptions {
switch (variant) {
case 'perf':
return { includePrerelease: true, includePerfPrerelease: true }
case 'prerelease':
return { includePrerelease: true }
case 'default':
return { includePrerelease: false }
}
}
function getUpdateCheckVariant(options?: UpdateCheckOptions): UpdateCheckVariant {
if (options?.includePerfPrerelease) {
return 'perf'
}
if (options?.includePrerelease) {
return 'prerelease'
}
return 'default'
}
function launchPendingUserInitiatedCheckAfterInFlight(variant: UpdateCheckVariant): void {
pendingUserInitiatedCheckAfterInFlight = null
setTimeout(() => {
// Why: electron-updater clears its in-flight promise after emitting the
// terminal event. Deferring one tick lets the queued RC check start fresh
// instead of being deduped into the just-finished stable check.
// terminal event. Deferring one tick lets the queued modifier check start
// fresh instead of being deduped into the just-finished stable check.
if (currentStatus.state === 'checking') {
currentStatus = { state: 'idle' }
}
checkForUpdatesFromMenu({ includePrerelease: true })
checkForUpdatesFromMenu(getOptionsForUpdateCheckVariant(variant))
}, 0)
}
@@ -769,7 +793,9 @@ function markMissingManifestPrereleaseFallbackPromiseHandled(message: string): v
)
}
async function pinDefaultReleaseFeed(): Promise<void> {
async function pinDefaultReleaseFeed(
variant: UpdateCheckVariant = 'default'
): Promise<ReleaseFeedPreflightResult> {
const autoUpdater = getAutoUpdater()
// Why: the /releases/latest/download/ redirect can move between the update
// check and the later manual download click. Pinning to the concrete tag
@@ -778,12 +804,15 @@ async function pinDefaultReleaseFeed(): Promise<void> {
// Prerelease users still need any-channel resolution so they can move to a
// newer RC or the next stable. Stable users should only resolve stable tags.
const currentVersion = app.getVersion()
const includePrerelease = includePrereleaseActive || isPrereleaseVersion(currentVersion)
const isPerfCheck = variant === 'perf'
const includePrerelease =
isPerfCheck || includePrereleaseActive || isPrereleaseVersion(currentVersion)
const releaseTagsResult = await fetchNewerReleaseTagsWithReadiness(
currentVersion,
includePrerelease ? 2 : 1,
{
includePrerelease
includePrerelease,
...(isPerfCheck ? { releaseFilter: 'perf' as const } : {})
}
)
const newerTag = releaseTagsResult.tags[0] ?? null
@@ -814,6 +843,7 @@ async function pinDefaultReleaseFeed(): Promise<void> {
`[updater] release feed pinned: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}`
)
autoUpdater.setFeedURL({ provider: 'generic', url })
return 'ready'
} else if (releaseTagsResult.state === 'not-ready') {
clearPrereleaseFallbackContext()
if (releaseTagsResult.lastGoodTag) {
@@ -825,13 +855,23 @@ async function pinDefaultReleaseFeed(): Promise<void> {
)
publishingWindowLastGoodCheck = { lastGoodTag: releaseTagsResult.lastGoodTag }
autoUpdater.setFeedURL({ provider: 'generic', url })
return
return 'ready'
}
clearPublishingWindowLastGoodCheck()
console.info(
`[updater] release feed deferred: current=${currentVersion} includePrerelease=${includePrerelease}; newest release assets are still publishing`
)
throw new Error('Latest release assets are still publishing')
} else if (isPerfCheck) {
clearPrereleaseFallbackContext()
clearPublishingWindowLastGoodCheck()
if (releaseTagsResult.state === 'no-newer') {
console.info(
`[updater] perf release not found: current=${currentVersion} includePrerelease=${includePrerelease}`
)
return 'not-available'
}
throw new Error('Could not resolve perf update feed')
} else {
clearPrereleaseFallbackContext()
clearPublishingWindowLastGoodCheck()
@@ -840,6 +880,7 @@ async function pinDefaultReleaseFeed(): Promise<void> {
`[updater] release feed fallback: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}`
)
autoUpdater.setFeedURL({ provider: 'generic', url })
return 'ready'
}
}
@@ -985,13 +1026,14 @@ function enableIncludePrerelease(): void {
}
/** Menu-triggered check — delegates feedback to renderer toasts via userInitiated flag */
export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean }): void {
export function checkForUpdatesFromMenu(options?: UpdateCheckOptions): void {
if (!app.isPackaged || is.dev) {
sendStatus({ state: 'not-available', userInitiated: true })
return
}
if (options?.includePrerelease) {
const checkVariant = getUpdateCheckVariant(options)
if (checkVariant !== 'default') {
clearPrereleaseFallbackContext()
enableIncludePrerelease()
}
@@ -1009,10 +1051,10 @@ export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean
if (checkAlreadyInFlight) {
backgroundCheckPromotedToUserInitiated = true
rearmActiveUpdateCheckStallTimer()
if (options?.includePrerelease) {
// Why: the in-flight check may have already pinned the stable feed.
// Queue a fresh RC check so Shift-click doesn't inherit a stable result.
pendingPrereleaseUserInitiatedCheckAfterInFlight = true
if (checkVariant !== 'default') {
// Why: the in-flight check may have already pinned the stable feed. Queue
// a fresh modifier check so it doesn't inherit a stale-channel result.
pendingUserInitiatedCheckAfterInFlight = checkVariant
}
return
}
@@ -1026,9 +1068,26 @@ export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean
markUpdateCheckLaunched(attemptId)
return autoUpdater.checkForUpdates()
}
const run = pinDefaultReleaseFeed().then(launch)
const run = pinDefaultReleaseFeed(checkVariant).then((preflightResult) => {
if (preflightResult === 'not-available') {
if (!isActiveUpdateCheckAttempt(attemptId)) {
return false
}
userInitiatedCheck = false
finishActiveUpdateCheckAttempt()
recordCompletedUpdateCheck()
sendStatus({ state: 'not-available', userInitiated: true })
return false
}
return launch()
})
void Promise.resolve(run)
.then(() => handleSettledUpdateCheckPromise(attemptId))
.then((launchResult) => {
if (launchResult === false) {
return
}
handleSettledUpdateCheckPromise(attemptId)
})
.catch((err) => {
if (!isActiveUpdateCheckAttempt(attemptId)) {
return
@@ -4,7 +4,11 @@ import { randomUUID } from 'node:crypto'
import { app, ipcMain } from 'electron'
import type { BrowserWindow } from 'electron'
import type { Store } from '../persistence'
import type { CreateWorktreeResult, WorktreeStartupLaunch } from '../../shared/types'
import type {
CreateWorktreeResult,
UpdateCheckOptions,
WorktreeStartupLaunch
} from '../../shared/types'
import { registerRepoHandlers } from '../ipc/repos'
import { registerWorktreeHandlers } from '../ipc/worktrees'
import { registerWorkspaceCleanupHandlers } from '../ipc/workspace-cleanup'
@@ -445,7 +449,7 @@ export function registerUpdaterHandlers(_store: Store): void {
ipcMain.handle('updater:getStatus', () => getUpdateStatus())
ipcMain.handle('updater:getVersion', () => app.getVersion())
ipcMain.handle('updater:check', (_event, options?: { includePrerelease?: boolean }) => {
ipcMain.handle('updater:check', (_event, options?: UpdateCheckOptions) => {
ensureAutoUpdaterConfigured()
return checkForUpdatesFromMenu(options)
})
+2 -1
View File
@@ -162,6 +162,7 @@ import type {
StatsSummary,
MemorySnapshot,
TuiAgent,
UpdateCheckOptions,
UpdateStatus,
Worktree,
WorktreeBaseStatusEvent,
@@ -2158,7 +2159,7 @@ export type PreloadApi = {
updater: {
getVersion: () => Promise<string>
getStatus: () => Promise<UpdateStatus>
check: (options?: { includePrerelease?: boolean }) => Promise<void>
check: (options?: UpdateCheckOptions) => Promise<void>
download: () => Promise<void>
quitAndInstall: () => Promise<void>
dismissNudge: () => Promise<void>
@@ -7,6 +7,10 @@ import { Button } from '../ui/button'
import { SearchableSetting } from './SearchableSetting'
import { SettingsSubsectionHeader } from './SettingsFormControls'
import { translate } from '@/i18n/i18n'
import {
getPerfUpdateModifierLabel,
getUpdateCheckClickOptions
} from '@/lib/update-check-click-options'
export function GeneralUpdateSettingsSection(): React.JSX.Element {
const updateStatus = useAppStore((s) => s.updateStatus)
@@ -36,6 +40,8 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element {
}
const [appVersion, setAppVersion] = useState<string | null>(null)
const perfUpdateModifierLabel = getPerfUpdateModifierLabel()
const updateCheckHint = `Shift-click checks the latest RC; ${perfUpdateModifierLabel}-click checks the latest perf build.`
useEffect(() => {
let cancelled = false
@@ -87,14 +93,10 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element {
<Button
variant="outline"
size="sm"
// Why: Shift-click opts this check into the release-candidate
// channel. Keep the affordance hidden; it's a power-user
// shortcut, not a discoverable toggle.
onClick={(event) =>
window.api.updater.check({
includePrerelease: event.shiftKey
})
}
// Why: modifier-click channels are power-user update affordances, not
// persistent settings toggles.
onClick={(event) => window.api.updater.check(getUpdateCheckClickOptions(event))}
title={updateCheckHint}
disabled={updateStatus.state === 'checking' || updateStatus.state === 'downloading'}
className="gap-2"
>
@@ -58,8 +58,23 @@ vi.mock('../setup-guide/SetupGuideProgressRing', () => ({
vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}</>,
DropdownMenuContent: ({ children }: { children: ReactNode }) => <>{children}</>,
DropdownMenuItem: ({ children, onSelect }: { children: ReactNode; onSelect?: () => void }) => (
<button data-testid="menu-item" onClick={onSelect}>
DropdownMenuItem: ({
children,
disabled,
onSelect,
title
}: {
children: ReactNode
disabled?: boolean
onSelect?: (event: Event) => void
title?: string
}) => (
<button
data-testid="menu-item"
disabled={disabled}
onClick={(event) => onSelect?.(event.nativeEvent)}
title={title}
>
{children}
</button>
),
@@ -250,6 +265,32 @@ describe('SidebarSettingsHelpMenu', () => {
it('renders Check for Updates menu item', () => {
const html = renderToStaticMarkup(<SidebarSettingsHelpMenu />)
expect(html).toContain('Check for Updates')
expect(html).toContain('Shift-click checks the latest RC')
expect(html).toMatch(/(⌘|Ctrl)-click checks the latest perf build/)
})
it('passes update-check modifier options through the updater bridge', async () => {
const container = await renderMenu()
const checkButton = findMenuItem(container, 'Check for Updates')
const primaryModifier = navigator.userAgent.includes('Mac')
? { metaKey: true }
: { ctrlKey: true }
await act(async () => {
checkButton.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true }))
})
await act(async () => {
checkButton.dispatchEvent(new MouseEvent('click', { bubbles: true, ...primaryModifier }))
})
expect(mocks.updaterCheck).toHaveBeenNthCalledWith(1, {
includePrerelease: true,
includePerfPrerelease: false
})
expect(mocks.updaterCheck).toHaveBeenNthCalledWith(2, {
includePrerelease: false,
includePerfPrerelease: true
})
})
it('renders shortcut keys in the settings tooltip', () => {
@@ -33,6 +33,10 @@ import { SetupGuideProgressRing } from '../setup-guide/SetupGuideProgressRing'
import { useSetupGuideProgress } from '../setup-guide/use-setup-guide-progress'
import { SidebarFeedbackDialog } from './SidebarFeedbackDialog'
import { translate } from '@/i18n/i18n'
import {
getPerfUpdateModifierLabel,
getUpdateCheckClickOptions
} from '@/lib/update-check-click-options'
const DOCS_URL = 'https://www.onorca.dev/docs'
const CHANGELOG_URL = 'https://onorca.dev/changelog'
@@ -92,6 +96,8 @@ export function SidebarSettingsHelpMenu(): React.JSX.Element {
const [isRestartingOrca, setIsRestartingOrca] = useState(false)
const lastShowOnboardingAtRef = React.useRef(0)
const mountedRef = useMountedRef()
const perfUpdateModifierLabel = getPerfUpdateModifierLabel()
const updateCheckHint = `Shift-click checks the latest RC; ${perfUpdateModifierLabel}-click checks the latest perf build.`
const showMilestones =
setupProgress.ready && setupProgress.coreDoneCount < setupProgress.coreTotal
@@ -148,8 +154,7 @@ export function SidebarSettingsHelpMenu(): React.JSX.Element {
}
const handleCheckForUpdates = (event: Event): void => {
const shiftKey = (event as PointerEvent).shiftKey
void window.api.updater.check({ includePrerelease: shiftKey })
void window.api.updater.check(getUpdateCheckClickOptions(event as PointerEvent))
}
const openMilestones = (): void => {
@@ -300,6 +305,7 @@ export function SidebarSettingsHelpMenu(): React.JSX.Element {
<DropdownMenuItem
disabled={updateStatus.state === 'checking' || updateStatus.state === 'downloading'}
onSelect={handleCheckForUpdates}
title={updateCheckHint}
>
{updateStatus.state === 'checking' ? (
<Loader2 className="size-3.5 animate-spin" />
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import {
getPerfUpdateModifierLabel,
getUpdateCheckClickOptions
} from './update-check-click-options'
function clickEvent(overrides: Partial<Pick<MouseEvent, 'ctrlKey' | 'metaKey' | 'shiftKey'>>) {
return {
ctrlKey: false,
metaKey: false,
shiftKey: false,
...overrides
} as Pick<MouseEvent, 'ctrlKey' | 'metaKey' | 'shiftKey'>
}
describe('getUpdateCheckClickOptions', () => {
it('uses Cmd on macOS for perf prerelease checks', () => {
expect(getUpdateCheckClickOptions(clickEvent({ metaKey: true }), true)).toEqual({
includePrerelease: false,
includePerfPrerelease: true
})
expect(getUpdateCheckClickOptions(clickEvent({ ctrlKey: true }), true)).toEqual({
includePrerelease: false,
includePerfPrerelease: false
})
})
it('uses Ctrl outside macOS for perf prerelease checks', () => {
expect(getUpdateCheckClickOptions(clickEvent({ ctrlKey: true }), false)).toEqual({
includePrerelease: false,
includePerfPrerelease: true
})
expect(getUpdateCheckClickOptions(clickEvent({ metaKey: true }), false)).toEqual({
includePrerelease: false,
includePerfPrerelease: false
})
})
it('keeps Shift as the RC prerelease modifier', () => {
expect(
getUpdateCheckClickOptions(clickEvent({ shiftKey: true, ctrlKey: true }), false)
).toEqual({
includePrerelease: true,
includePerfPrerelease: true
})
})
it('formats the perf modifier label by platform', () => {
expect(getPerfUpdateModifierLabel(true)).toBe('⌘')
expect(getPerfUpdateModifierLabel(false)).toBe('Ctrl')
})
})
@@ -0,0 +1,22 @@
import type { UpdateCheckOptions } from '../../../shared/types'
import { getShortcutPlatform } from './shortcut-platform'
type UpdateCheckClickEvent = Pick<MouseEvent, 'ctrlKey' | 'metaKey' | 'shiftKey'>
function isMacShortcutPlatform(): boolean {
return getShortcutPlatform() === 'darwin'
}
export function getPerfUpdateModifierLabel(isMac = isMacShortcutPlatform()): string {
return isMac ? '⌘' : 'Ctrl'
}
export function getUpdateCheckClickOptions(
event: UpdateCheckClickEvent,
isMac = isMacShortcutPlatform()
): UpdateCheckOptions {
return {
includePrerelease: event.shiftKey,
includePerfPrerelease: isMac ? event.metaKey : event.ctrlKey
}
}
+5
View File
@@ -2182,6 +2182,11 @@ export type ChangelogData = {
releasesBehind: number | null
}
export type UpdateCheckOptions = {
includePrerelease?: boolean
includePerfPrerelease?: boolean
}
export type UpdateStatus =
| { state: 'idle' }
| { state: 'checking'; userInitiated?: boolean }