fix(desktop): show 'update available' instead of fake '1 change included' on shallow clones
On an installer checkout (clone --depth 1) with no merge-base against the freshly fetched origin tip, resolveBehindCount returned the sentinel 1 and every surface rendered it as a literal count: 'A new update is ready (1 change included).' — even when the true distance was far larger (observed: 90 commits). The sentinel was meant to mean 'update available, exact count unknown', but nothing downstream distinguished it from a real one. - update-count.ts: return null (unknown) instead of the numeric sentinel - main.ts: flag updateAvailable explicitly and still serve the (capped) commit log so 'See what's new' stays useful in the unknown case - updates.ts: toast fires for behind:null + updateAvailable, with count-free copy instead of being swallowed by the <= 0 guard - about-settings.tsx: status line and action buttons key off updateAvailable; unknown size renders the new count-free string - i18n: updateReadyUnknown / updateReadyMessageUnknown in all 5 locales Refs #51922 (the shallow-clone special case this UI now renders honestly). Tests: vitest electron 10/10, ui 44/44 (3 FAIL-BEFORE reds turned green), tsc typecheck clean, eslint clean on all touched files.
This commit is contained in:
@@ -2633,13 +2633,17 @@ async function checkUpdates() {
|
||||
hasMergeBase
|
||||
})
|
||||
|
||||
const commits = behind > 0 ? await readCommitLog(updateRoot, branch) : []
|
||||
// behind === null means "update available, exact count unknown" (shallow
|
||||
// clone without a merge-base): still list what origin offers (the log read
|
||||
// is capped at 40 entries), so "See what's new" stays useful and honest.
|
||||
const commits = behind !== 0 ? await readCommitLog(updateRoot, branch) : []
|
||||
|
||||
return {
|
||||
supported: true,
|
||||
branch,
|
||||
currentBranch,
|
||||
behind,
|
||||
updateAvailable: behind === null || behind > 0,
|
||||
currentSha,
|
||||
targetSha,
|
||||
commits,
|
||||
|
||||
@@ -4,10 +4,11 @@ import { test } from 'vitest'
|
||||
|
||||
import { resolveBehindCount, shouldCountCommits } from './update-count'
|
||||
|
||||
// FAIL-BEFORE: pre-fix the function did `Number.parseInt(countStr) || 0`
|
||||
// unconditionally, so a shallow checkout with no merge-base surfaced the bogus
|
||||
// rev-list count (e.g. 12104). This asserts the new shallow/no-merge-base branch.
|
||||
test('shallow checkout with no merge-base does NOT trust the bogus rev-list count', () => {
|
||||
// FAIL-BEFORE: the shallow/no-merge-base branch returned the sentinel `1`,
|
||||
// which the UI rendered as a literal "1 change included" even when the true
|
||||
// count was far higher (e.g. 90). An update IS available here, but its exact
|
||||
// size is unknown — the only honest value is `null`.
|
||||
test('shallow checkout with no merge-base reports null (unknown count), not a fake 1', () => {
|
||||
assert.equal(
|
||||
resolveBehindCount({
|
||||
countStr: '12104',
|
||||
@@ -16,7 +17,7 @@ test('shallow checkout with no merge-base does NOT trust the bogus rev-list coun
|
||||
isShallow: true,
|
||||
hasMergeBase: false
|
||||
}),
|
||||
1
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
@@ -113,7 +114,7 @@ test('skipped-count path resolves via SHA compare, never via empty countStr', ()
|
||||
isShallow: true,
|
||||
hasMergeBase: false
|
||||
}),
|
||||
1
|
||||
null
|
||||
)
|
||||
assert.equal(
|
||||
resolveBehindCount({
|
||||
|
||||
@@ -21,7 +21,11 @@ function resolveBehindCount({ countStr, currentSha, targetSha, isShallow, hasMer
|
||||
return 0
|
||||
}
|
||||
|
||||
return 1 // behind by an unknown amount — show a generic "update available"
|
||||
// An update IS available, but its size is unknowable without a merge-base.
|
||||
// Return null — never a numeric sentinel: the UI used to render the old
|
||||
// `1` as a literal "1 change included" even when the true distance was
|
||||
// far larger. null lets every surface say "update available" honestly.
|
||||
return null
|
||||
}
|
||||
|
||||
return Number.parseInt(countStr, 10) || 0
|
||||
|
||||
@@ -63,6 +63,9 @@ export function AboutSettings() {
|
||||
}, [])
|
||||
|
||||
const behind = status?.behind ?? 0
|
||||
// behind is null when the exact count is unknowable (shallow clone): the
|
||||
// backend flags that case via updateAvailable instead of a number.
|
||||
const updateAvailable = behind > 0 || Boolean(status?.updateAvailable)
|
||||
const supported = status?.supported !== false
|
||||
const applying = apply.applying || apply.stage === 'restart'
|
||||
|
||||
@@ -84,8 +87,8 @@ export function AboutSettings() {
|
||||
} else if (applying) {
|
||||
statusLine = a.installing
|
||||
statusTone = 'available'
|
||||
} else if (behind > 0) {
|
||||
statusLine = a.updateReady(behind)
|
||||
} else if (updateAvailable) {
|
||||
statusLine = behind > 0 ? a.updateReady(behind) : a.updateReadyUnknown
|
||||
statusTone = 'available'
|
||||
} else if (status) {
|
||||
statusLine = a.onLatest
|
||||
@@ -142,7 +145,7 @@ export function AboutSettings() {
|
||||
{checking ? a.checking : a.checkNow}
|
||||
</Button>
|
||||
|
||||
{behind > 0 && supported && !applying && (
|
||||
{updateAvailable && supported && !applying && (
|
||||
<>
|
||||
<Button onClick={() => startActiveUpdate()} size="sm">
|
||||
{a.updateNow}
|
||||
|
||||
5
apps/desktop/src/global.d.ts
vendored
5
apps/desktop/src/global.d.ts
vendored
@@ -439,7 +439,10 @@ export interface DesktopUpdateStatus {
|
||||
reason?: string
|
||||
message?: string
|
||||
error?: string
|
||||
behind?: number
|
||||
/** Exact commits behind. null = update available, but the count is
|
||||
* unknowable (shallow clone without a merge-base) — never render it as a
|
||||
* literal number. */
|
||||
behind?: number | null
|
||||
currentSha?: string
|
||||
/** Backend only: the version string the backend reports for itself. */
|
||||
currentVersion?: string
|
||||
|
||||
@@ -113,6 +113,7 @@ export const ar = defineLocale({
|
||||
updateHermes: 'تحديث Hermes',
|
||||
updateReadyTitle: 'التحديث جاهز',
|
||||
updateReadyMessage: count => `${count} تغيير جديد متاح.`,
|
||||
updateReadyMessageUnknown: 'يتوفر تحديث جديد.',
|
||||
seeWhatsNew: 'عرض الجديد',
|
||||
errors: {
|
||||
elevenLabsNeedsKey: 'يتطلب ElevenLabs STT المفتاح ELEVENLABS_API_KEY.',
|
||||
@@ -602,6 +603,7 @@ export const ar = defineLocale({
|
||||
cantReach: 'تعذر الوصول لخدمة التحديث',
|
||||
tapCheck: 'اضغط للتحقق من التحديثات.',
|
||||
updateReady: count => `${count} تحديث متاح`,
|
||||
updateReadyUnknown: 'تحديث جديد جاهز.',
|
||||
lastChecked: age => `آخر تحقق ${age}`,
|
||||
justNowSuffix: 'الآن',
|
||||
automaticUpdates: 'التحديثات التلقائية',
|
||||
|
||||
@@ -128,6 +128,7 @@ export const en: Translations = {
|
||||
updateHermes: 'Update Hermes',
|
||||
updateReadyTitle: 'Update ready',
|
||||
updateReadyMessage: count => `${count} new change${count === 1 ? '' : 's'} available.`,
|
||||
updateReadyMessageUnknown: 'A new update is available.',
|
||||
seeWhatsNew: "See what's new",
|
||||
errors: {
|
||||
elevenLabsNeedsKey: 'ElevenLabs STT needs ELEVENLABS_API_KEY.',
|
||||
@@ -553,6 +554,7 @@ export const en: Translations = {
|
||||
cantReach: "We couldn't reach the update server.",
|
||||
tapCheck: 'Tap "Check now" to look for updates.',
|
||||
updateReady: count => `A new update is ready (${count} change${count === 1 ? '' : 's'} included).`,
|
||||
updateReadyUnknown: 'A new update is ready.',
|
||||
lastChecked: age => `Last checked ${age}`,
|
||||
justNowSuffix: ' · just now',
|
||||
automaticUpdates: 'Automatic updates',
|
||||
|
||||
@@ -129,6 +129,7 @@ export const ja = defineLocale({
|
||||
updateHermes: 'Hermes を更新',
|
||||
updateReadyTitle: '更新の準備ができました',
|
||||
updateReadyMessage: count => `${count} 件の新しい変更が利用可能です。`,
|
||||
updateReadyMessageUnknown: '新しい更新が利用可能です。',
|
||||
seeWhatsNew: '新機能を見る',
|
||||
errors: {
|
||||
elevenLabsNeedsKey: 'ElevenLabs STT には ELEVENLABS_API_KEY が必要です。',
|
||||
@@ -637,6 +638,7 @@ export const ja = defineLocale({
|
||||
cantReach: '更新サーバーに接続できませんでした。',
|
||||
tapCheck: '更新を探すには「今すぐ確認」を押してください。',
|
||||
updateReady: count => `新しい更新の準備ができました (${count} 件の変更を含みます)。`,
|
||||
updateReadyUnknown: '新しい更新の準備ができました。',
|
||||
lastChecked: age => `前回確認: ${age}`,
|
||||
justNowSuffix: ' · たった今',
|
||||
automaticUpdates: '自動更新',
|
||||
|
||||
@@ -170,6 +170,7 @@ export interface Translations {
|
||||
updateHermes: string
|
||||
updateReadyTitle: string
|
||||
updateReadyMessage: (count: number) => string
|
||||
updateReadyMessageUnknown: string
|
||||
seeWhatsNew: string
|
||||
errors: {
|
||||
elevenLabsNeedsKey: string
|
||||
@@ -450,6 +451,7 @@ export interface Translations {
|
||||
cantReach: string
|
||||
tapCheck: string
|
||||
updateReady: (count: number) => string
|
||||
updateReadyUnknown: string
|
||||
lastChecked: (age: string) => string
|
||||
justNowSuffix: string
|
||||
automaticUpdates: string
|
||||
|
||||
@@ -125,6 +125,7 @@ export const zhHant = defineLocale({
|
||||
updateHermes: '更新 Hermes',
|
||||
updateReadyTitle: '有可用更新',
|
||||
updateReadyMessage: count => `有 ${count} 項新變更可用。`,
|
||||
updateReadyMessageUnknown: '有新更新可用。',
|
||||
seeWhatsNew: '查看新增內容',
|
||||
errors: {
|
||||
elevenLabsNeedsKey: 'ElevenLabs STT 需要 ELEVENLABS_API_KEY。',
|
||||
@@ -624,6 +625,7 @@ export const zhHant = defineLocale({
|
||||
cantReach: '無法連線到更新伺服器。',
|
||||
tapCheck: '點選「立即檢查」以尋找更新。',
|
||||
updateReady: count => `新更新已就緒(包含 ${count} 項變更)。`,
|
||||
updateReadyUnknown: '新更新已就緒。',
|
||||
lastChecked: age => `上次檢查:${age}`,
|
||||
justNowSuffix: ' · 剛剛',
|
||||
automaticUpdates: '自動更新',
|
||||
|
||||
@@ -125,6 +125,7 @@ export const zh: Translations = {
|
||||
updateHermes: '更新 Hermes',
|
||||
updateReadyTitle: '有可用更新',
|
||||
updateReadyMessage: count => `有 ${count} 项新更改可用。`,
|
||||
updateReadyMessageUnknown: '有新更新可用。',
|
||||
seeWhatsNew: '查看更新内容',
|
||||
errors: {
|
||||
elevenLabsNeedsKey: 'ElevenLabs STT 需要 ELEVENLABS_API_KEY。',
|
||||
@@ -762,6 +763,7 @@ export const zh: Translations = {
|
||||
cantReach: '无法连接更新服务器。',
|
||||
tapCheck: '点击"立即检查"以查找更新。',
|
||||
updateReady: count => `已准备好新更新 (包含 ${count} 项更改)。`,
|
||||
updateReadyUnknown: '新更新已就绪。',
|
||||
lastChecked: age => `上次检查:${age}`,
|
||||
justNowSuffix: ' · 刚刚',
|
||||
automaticUpdates: '自动更新',
|
||||
|
||||
@@ -123,6 +123,15 @@ describe('maybeNotifyUpdateAvailable', () => {
|
||||
maybeNotifyUpdateAvailable(status({ behind: 0 }))
|
||||
expect(notifySpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// FAIL-BEFORE: a shallow installer clone reports behind:null + updateAvailable
|
||||
// (exact count unknowable without a merge-base). The guard treated null as 0
|
||||
// and silently swallowed the notification entirely.
|
||||
it('still notifies with generic copy when the exact behind count is unknown', () => {
|
||||
maybeNotifyUpdateAvailable(status({ behind: null, updateAvailable: true }))
|
||||
expect(notifySpy).toHaveBeenCalledTimes(1)
|
||||
expect(notifySpy.mock.calls[0]?.[0]).toMatchObject({ message: 'A new update is available.' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('reportBackendContract', () => {
|
||||
|
||||
@@ -206,7 +206,11 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
|
||||
return
|
||||
}
|
||||
|
||||
if ((status.behind ?? 0) <= 0) {
|
||||
const behind = typeof status.behind === 'number' ? status.behind : null
|
||||
|
||||
// behind === null means "update available, exact count unknown" (shallow
|
||||
// clone). That still deserves the toast — just with count-free copy.
|
||||
if ((behind ?? 0) <= 0 && !status.updateAvailable) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -218,8 +222,6 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
|
||||
return
|
||||
}
|
||||
|
||||
const behind = status.behind ?? 0
|
||||
|
||||
notify({
|
||||
action: {
|
||||
label: translateNow('notifications.seeWhatsNew'),
|
||||
@@ -232,7 +234,10 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
|
||||
icon: 'gift',
|
||||
id: UPDATE_TOAST_ID,
|
||||
kind: 'info',
|
||||
message: translateNow('notifications.updateReadyMessage', behind),
|
||||
message:
|
||||
behind !== null && behind > 0
|
||||
? translateNow('notifications.updateReadyMessage', behind)
|
||||
: translateNow('notifications.updateReadyMessageUnknown'),
|
||||
onDismiss: () => snoozeUpdateToast(),
|
||||
title: translateNow('notifications.updateReadyTitle')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user