| author | |
| committer | |
| log | 5014e5fcdd6e5f4a53d0fea4cbecba733821a854 |
| tree | a76dc27e13b7e3f73e08953253d725e5bb9f3576 |
| parent | fc53b273039cf5e0a932582b1fbb56e7223ad4ab |
| signature | Signed by PGP key B5690EEEBB952194 |
17 files changed, 804 insertions(+), 181 deletions(-)
apps/desktop/src/updates/DesktopUpdates.test.ts+23-3| ... | ... | @@ -336,6 +336,11 @@ describe("DesktopUpdates", () => { |
| 336 | 336 | version: "1.2.4-nightly.20260709.765", |
| 337 | 337 | note: "- [codex] Upgrade Clerk stack by @juliusmarminge in #3821", |
| 338 | 338 | }, |
| 339 | { version: "1.2.4-nightly.20260709.764", note: "- Change 764" }, | |
| 340 | { version: "1.2.4-nightly.20260709.763", note: "- Change 763" }, | |
| 341 | { version: "1.2.4-nightly.20260709.762", note: "- Change 762" }, | |
| 342 | { version: "1.2.4-nightly.20260709.761", note: "- Change 761" }, | |
| 343 | { version: "1.2.4-nightly.20260709.760", note: "- Change 760" }, | |
| 339 | 344 | ], |
| 340 | 345 | }); |
| 341 | 346 | yield* flushCallbacks; |
| ... | ... | @@ -346,13 +351,21 @@ describe("DesktopUpdates", () => { |
| 346 | 351 | { |
| 347 | 352 | version: "1.2.4-nightly.20260709.766", |
| 348 | 353 | items: ["feat(client): persist offline environment data by @juliusmarminge in #3795"], |
| 354 | totalItems: 1, | |
| 349 | 355 | }, |
| 350 | 356 | { |
| 351 | 357 | version: "1.2.4-nightly.20260709.765", |
| 352 | 358 | items: ["[codex] Upgrade Clerk stack by @juliusmarminge in #3821"], |
| 359 | totalItems: 1, | |
| 353 | 360 | }, |
| 361 | { version: "1.2.4-nightly.20260709.764", items: ["Change 764"], totalItems: 1 }, | |
| 362 | { version: "1.2.4-nightly.20260709.763", items: ["Change 763"], totalItems: 1 }, | |
| 363 | { version: "1.2.4-nightly.20260709.762", items: ["Change 762"], totalItems: 1 }, | |
| 364 | { version: "1.2.4-nightly.20260709.761", items: ["Change 761"], totalItems: 1 }, | |
| 354 | 365 | ]); |
| 366 | assert.equal(state.omittedReleaseCount, 1); | |
| 355 | 367 | assert.deepEqual(harness.sentStates.at(-1)?.releaseNotes, state.releaseNotes); |
| 368 | assert.equal(harness.sentStates.at(-1)?.omittedReleaseCount, 1); | |
| 356 | 369 | }), |
| 357 | 370 | ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); |
| 358 | 371 | }); |
| ... | ... | @@ -383,8 +396,9 @@ describe("DesktopUpdates", () => { |
| 383 | 396 | assert.equal(unchangedState.status, "downloaded"); |
| 384 | 397 | assert.equal(unchangedState.downloadedVersion, "1.2.4"); |
| 385 | 398 | assert.deepEqual(unchangedState.releaseNotes, [ |
| 386 | { version: "1.2.4", items: ["fix: queued update"] }, | |
| 399 | { version: "1.2.4", items: ["fix: queued update"], totalItems: 1 }, | |
| 387 | 400 | ]); |
| 401 | assert.equal(unchangedState.omittedReleaseCount, 0); | |
| 388 | 402 | |
| 389 | 403 | const nextResult = yield* updates.check("poll"); |
| 390 | 404 | assert.isTrue(nextResult.checked); |
| ... | ... | @@ -424,7 +438,10 @@ describe("DesktopUpdates", () => { |
| 424 | 438 | assert.equal(state.status, "downloaded"); |
| 425 | 439 | assert.equal(state.availableVersion, "1.2.4"); |
| 426 | 440 | assert.equal(state.downloadedVersion, "1.2.4"); |
| 427 | assert.deepEqual(state.releaseNotes, [{ version: "1.2.4", items: ["fix: queued update"] }]); | |
| 441 | assert.deepEqual(state.releaseNotes, [ | |
| 442 | { version: "1.2.4", items: ["fix: queued update"], totalItems: 1 }, | |
| 443 | ]); | |
| 444 | assert.equal(state.omittedReleaseCount, 0); | |
| 428 | 445 | assert.equal(state.downloadPercent, 100); |
| 429 | 446 | }), |
| 430 | 447 | ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); |
| ... | ... | @@ -454,7 +471,10 @@ describe("DesktopUpdates", () => { |
| 454 | 471 | assert.equal(state.status, "downloaded"); |
| 455 | 472 | assert.equal(state.availableVersion, "1.2.4"); |
| 456 | 473 | assert.equal(state.downloadedVersion, "1.2.4"); |
| 457 | assert.deepEqual(state.releaseNotes, [{ version: "1.2.4", items: ["fix: queued update"] }]); | |
| 474 | assert.deepEqual(state.releaseNotes, [ | |
| 475 | { version: "1.2.4", items: ["fix: queued update"], totalItems: 1 }, | |
| 476 | ]); | |
| 477 | assert.equal(state.omittedReleaseCount, 0); | |
| 458 | 478 | assert.equal(state.downloadPercent, 100); |
| 459 | 479 | }), |
| 460 | 480 | ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); |
apps/desktop/src/updates/DesktopUpdates.ts+12-2| ... | ... | @@ -593,14 +593,24 @@ export const make = Effect.gen(function* () { |
| 593 | 593 | } |
| 594 | 594 | |
| 595 | 595 | const checkedAt = yield* currentIsoTimestamp; |
| 596 | const releaseNotes = normalizeDesktopUpdateReleaseNotes(info.releaseNotes, info.version); | |
| 596 | const { releaseNotes, omittedReleaseCount } = normalizeDesktopUpdateReleaseNotes( | |
| 597 | info.releaseNotes, | |
| 598 | info.version, | |
| 599 | ); | |
| 597 | 600 | yield* setState( |
| 598 | reduceDesktopUpdateStateOnUpdateAvailable(state, info.version, checkedAt, releaseNotes), | |
| 601 | reduceDesktopUpdateStateOnUpdateAvailable( | |
| 602 | state, | |
| 603 | info.version, | |
| 604 | checkedAt, | |
| 605 | releaseNotes, | |
| 606 | omittedReleaseCount, | |
| 607 | ), | |
| 599 | 608 | ); |
| 600 | 609 | yield* Ref.set(lastLoggedDownloadMilestoneRef, -1); |
| 601 | 610 | yield* logUpdaterInfo("update available", { |
| 602 | 611 | version: info.version, |
| 603 | 612 | releaseNoteGroups: releaseNotes.length, |
| 613 | omittedReleaseCount, | |
| 604 | 614 | }); |
| 605 | 615 | }), |
| 606 | 616 | ), |
apps/desktop/src/updates/releaseNotes.test.ts+129-28| ... | ... | @@ -3,38 +3,137 @@ import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts"; |
| 4 | 4 | |
| 5 | 5 | describe("normalizeDesktopUpdateReleaseNotes", () => { |
| 6 | it("splits a plain string note into items under the fallback version", () => { | |
| 7 | const notes = normalizeDesktopUpdateReleaseNotes( | |
| 8 | "## What's changed\n- First fix\n- Second fix", | |
| 6 | it("shows the newest changes and counts all real changes", () => { | |
| 7 | const result = normalizeDesktopUpdateReleaseNotes( | |
| 8 | [ | |
| 9 | "- feat: first change", | |
| 10 | "- fix: second change", | |
| 11 | "- fix: third change", | |
| 12 | "- fix: fourth change", | |
| 13 | "- fix: fifth change", | |
| 14 | "- fix: sixth change", | |
| 15 | "- fix: seventh change", | |
| 16 | "- fix: eighth change", | |
| 17 | "- fix(web): keep long task drawers usable on small screens by @human in #8313", | |
| 18 | "- fix(opencode): handle child approvals, stops, and model catalogs by @human in #8480", | |
| 19 | "## New Contributors", | |
| 20 | "- @human made their first contribution in #8435", | |
| 21 | "**Full Changelog**: https://github.com/pingdotgg/t3code/compare/old...new", | |
| 22 | ].join("\n"), | |
| 23 | "0.0.36-nightly.20260828.1213", | |
| 24 | ); | |
| 25 | ||
| 26 | expect(result).toEqual({ | |
| 27 | releaseNotes: [ | |
| 28 | { | |
| 29 | version: "0.0.36-nightly.20260828.1213", | |
| 30 | items: [ | |
| 31 | "fix(opencode): handle child approvals, stops, and model catalogs by @human in #8480", | |
| 32 | "fix(web): keep long task drawers usable on small screens by @human in #8313", | |
| 33 | "fix: eighth change", | |
| 34 | "fix: seventh change", | |
| 35 | "fix: sixth change", | |
| 36 | "fix: fifth change", | |
| 37 | "fix: fourth change", | |
| 38 | "fix: third change", | |
| 39 | ], | |
| 40 | totalItems: 10, | |
| 41 | }, | |
| 42 | ], | |
| 43 | omittedReleaseCount: 0, | |
| 44 | }); | |
| 45 | }); | |
| 46 | ||
| 47 | it("excludes a GitHub HTML contributor section", () => { | |
| 48 | const result = normalizeDesktopUpdateReleaseNotes( | |
| 49 | "<h2>What's Changed</h2><ul><li>Older fix</li><li>Newer fix</li></ul>" + | |
| 50 | "<h2>New Contributors</h2><ul><li>@human made their first contribution</li></ul>" + | |
| 51 | "<h2>Full Changelog</h2>", | |
| 9 | 52 | "1.2.3", |
| 10 | 53 | ); |
| 11 | expect(notes).toEqual([{ version: "1.2.3", items: ["First fix", "Second fix"] }]); | |
| 54 | ||
| 55 | expect(result).toEqual({ | |
| 56 | releaseNotes: [{ version: "1.2.3", items: ["Newer fix", "Older fix"], totalItems: 2 }], | |
| 57 | omittedReleaseCount: 0, | |
| 58 | }); | |
| 59 | }); | |
| 60 | ||
| 61 | it("does not count Markdown or HTML section headings as changes", () => { | |
| 62 | const changes = Array.from({ length: 8 }, (_, index) => `Change ${index + 1}`); | |
| 63 | const result = normalizeDesktopUpdateReleaseNotes( | |
| 64 | [ | |
| 65 | { version: "1.2.4", note: ["### Features", ...changes].join("\n- ") }, | |
| 66 | { | |
| 67 | version: "1.2.3", | |
| 68 | note: `<h3>Fixes</h3><ul>${changes.map((change) => `<li>${change}</li>`).join("")}</ul>`, | |
| 69 | }, | |
| 70 | ], | |
| 71 | "1.2.4", | |
| 72 | ); | |
| 73 | ||
| 74 | expect(result.releaseNotes).toEqual([ | |
| 75 | { version: "1.2.4", items: changes.toReversed(), totalItems: 8 }, | |
| 76 | { version: "1.2.3", items: changes.toReversed(), totalItems: 8 }, | |
| 77 | ]); | |
| 12 | 78 | }); |
| 13 | 79 | |
| 14 | it("keeps per-version groups and drops empty ones", () => { | |
| 15 | const notes = normalizeDesktopUpdateReleaseNotes( | |
| 80 | it("keeps per-version order and drops empty groups", () => { | |
| 81 | const result = normalizeDesktopUpdateReleaseNotes( | |
| 16 | 82 | [ |
| 17 | { version: "1.2.3", note: "- Newer change" }, | |
| 83 | { version: "1.2.3", note: "- Newer release" }, | |
| 18 | 84 | { version: "1.2.2", note: "Full changelog: https://example.com/compare/x...y" }, |
| 19 | { version: "1.2.1", note: "- Older change" }, | |
| 85 | { version: "1.2.1", note: "- Older release" }, | |
| 20 | 86 | ], |
| 21 | 87 | "1.2.3", |
| 22 | 88 | ); |
| 23 | expect(notes).toEqual([ | |
| 24 | { version: "1.2.3", items: ["Newer change"] }, | |
| 25 | { version: "1.2.1", items: ["Older change"] }, | |
| 89 | ||
| 90 | expect(result).toEqual({ | |
| 91 | releaseNotes: [ | |
| 92 | { version: "1.2.3", items: ["Newer release"], totalItems: 1 }, | |
| 93 | { version: "1.2.1", items: ["Older release"], totalItems: 1 }, | |
| 94 | ], | |
| 95 | omittedReleaseCount: 0, | |
| 96 | }); | |
| 97 | }); | |
| 98 | ||
| 99 | it("counts valid groups before applying the six-release limit", () => { | |
| 100 | const releaseNotes = [ | |
| 101 | { version: "1.3.9", note: "- Change 9" }, | |
| 102 | { version: "1.3.8", note: "Full changelog: https://example.com/compare/x...y" }, | |
| 103 | { version: "1.3.7", note: "- Change 7" }, | |
| 104 | { version: "1.3.6", note: "- Change 6" }, | |
| 105 | { version: "1.3.5", note: "- Change 5" }, | |
| 106 | { version: "1.3.4", note: "- Change 4" }, | |
| 107 | { version: "1.3.3", note: "- Change 3" }, | |
| 108 | { version: "1.3.2", note: "- Change 2" }, | |
| 109 | ]; | |
| 110 | ||
| 111 | const result = normalizeDesktopUpdateReleaseNotes(releaseNotes, "1.3.9"); | |
| 112 | ||
| 113 | expect(result.releaseNotes.map(({ version }) => version)).toEqual([ | |
| 114 | "1.3.9", | |
| 115 | "1.3.7", | |
| 116 | "1.3.6", | |
| 117 | "1.3.5", | |
| 118 | "1.3.4", | |
| 119 | "1.3.3", | |
| 26 | 120 | ]); |
| 121 | expect(result.omittedReleaseCount).toBe(1); | |
| 27 | 122 | }); |
| 28 | 123 | |
| 29 | 124 | it("decodes valid HTML entities", () => { |
| 30 | const notes = normalizeDesktopUpdateReleaseNotes("- Fix &amp; polish &#128512;", "1.0.0"); | |
| 31 | expect(notes).toEqual([{ version: "1.0.0", items: ["Fix & polish 😀"] }]); | |
| 125 | const result = normalizeDesktopUpdateReleaseNotes("- Fix &amp; polish &#128512;", "1.0.0"); | |
| 126 | expect(result).toEqual({ | |
| 127 | releaseNotes: [{ version: "1.0.0", items: ["Fix & polish 😀"], totalItems: 1 }], | |
| 128 | omittedReleaseCount: 0, | |
| 129 | }); | |
| 32 | 130 | }); |
| 33 | 131 | |
| 34 | it("ignores malformed entries instead of throwing", () => { | |
| 35 | const notes = normalizeDesktopUpdateReleaseNotes( | |
| 132 | it("ignores malformed and empty entries instead of throwing", () => { | |
| 133 | const result = normalizeDesktopUpdateReleaseNotes( | |
| 36 | 134 | [ |
| 37 | 135 | { version: "1.2.3", note: "- Valid change" }, |
| 136 | { version: "1.2.2", note: "" }, | |
| 38 | 137 | { version: 42, note: "- Bad version type" }, |
| 39 | 138 | { version: "1.2.1", note: { html: "<p>object note</p>" } }, |
| 40 | 139 | "not an object", |
| ... | ... | @@ -42,23 +141,25 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { |
| 42 | 141 | ], |
| 43 | 142 | "1.2.3", |
| 44 | 143 | ); |
| 45 | expect(notes).toEqual([{ version: "1.2.3", items: ["Valid change"] }]); | |
| 144 | ||
| 145 | expect(result).toEqual({ | |
| 146 | releaseNotes: [{ version: "1.2.3", items: ["Valid change"], totalItems: 1 }], | |
| 147 | omittedReleaseCount: 0, | |
| 148 | }); | |
| 46 | 149 | }); |
| 47 | 150 | |
| 48 | it("returns non-empty groups even when preceded by many boilerplate-only groups", () => { | |
| 49 | const boilerplate = Array.from({ length: 7 }, (_, index) => ({ | |
| 50 | version: `1.3.${9 - index}`, | |
| 51 | note: "Full changelog: https://example.com/compare/x...y", | |
| 52 | })); | |
| 53 | const notes = normalizeDesktopUpdateReleaseNotes( | |
| 54 | [...boilerplate, { version: "1.3.2", note: "- Older but real change" }], | |
| 55 | "1.3.9", | |
| 56 | ); | |
| 57 | expect(notes).toEqual([{ version: "1.3.2", items: ["Older but real change"] }]); | |
| 151 | it("returns an empty result for an invalid payload", () => { | |
| 152 | expect(normalizeDesktopUpdateReleaseNotes({ note: "- Invalid" }, "1.0.0")).toEqual({ | |
| 153 | releaseNotes: [], | |
| 154 | omittedReleaseCount: 0, | |
| 155 | }); | |
| 58 | 156 | }); |
| 59 | 157 | |
| 60 | 158 | it("does not throw on out-of-range numeric entities and keeps the literal", () => { |
| 61 | const notes = normalizeDesktopUpdateReleaseNotes("- Broken entity &#9999999999;", "1.0.0"); | |
| 62 | expect(notes).toEqual([{ version: "1.0.0", items: ["Broken entity &#9999999999;"] }]); | |
| 159 | const result = normalizeDesktopUpdateReleaseNotes("- Broken entity &#9999999999;", "1.0.0"); | |
| 160 | expect(result).toEqual({ | |
| 161 | releaseNotes: [{ version: "1.0.0", items: ["Broken entity &#9999999999;"], totalItems: 1 }], | |
| 162 | omittedReleaseCount: 0, | |
| 163 | }); | |
| 63 | 164 | }); |
| 64 | 165 | }); |
apps/desktop/src/updates/releaseNotes.ts+43-16| ... | ... | @@ -59,6 +59,7 @@ function stripMarkup(input: string): string { |
| 59 | 59 | input |
| 60 | 60 | .replace(/<br\s*\/?>/gi, "\n") |
| 61 | 61 | .replace(/<li\b[^>]*>/gi, "\n- ") |
| 62 | .replace(/<h([1-6])\b[^>]*>/gi, (_, level: string) => `\n${"#".repeat(Number(level))} `) | |
| 62 | 63 | .replace(/<\/(?:p|div|li|h[1-6]|ul|ol|blockquote)>/gi, "\n") |
| 63 | 64 | .replace(/<[^>]*>/g, "") |
| 64 | 65 | .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") |
| ... | ... | @@ -71,43 +72,60 @@ function truncateReleaseNoteItem(item: string): string { |
| 71 | 72 | return `${item.slice(0, MAX_RELEASE_NOTE_ITEM_LENGTH - 3).trimEnd()}...`; |
| 72 | 73 | } |
| 73 | 74 | |
| 74 | function isIgnoredReleaseNoteLine(line: string): boolean { | |
| 75 | const normalized = line | |
| 75 | function normalizeReleaseNoteLine(line: string): string { | |
| 76 | return line | |
| 76 | 77 | .toLowerCase() |
| 77 | 78 | .replace(/[*_`#]/g, "") |
| 78 | 79 | .trim(); |
| 80 | } | |
| 81 | ||
| 82 | function isIgnoredReleaseNoteLine(line: string): boolean { | |
| 83 | const normalized = normalizeReleaseNoteLine(line); | |
| 79 | 84 | return ( |
| 80 | 85 | normalized === "" || |
| 81 | 86 | normalized === "what's changed" || |
| 82 | 87 | normalized === "whats changed" || |
| 83 | normalized === "full changelog" || | |
| 84 | normalized === "new contributors" || | |
| 85 | 88 | normalized.startsWith("compare: ") || |
| 86 | 89 | normalized.includes("/compare/") |
| 87 | 90 | ); |
| 88 | 91 | } |
| 89 | 92 | |
| 90 | function extractReleaseNoteItems(note: string | null | undefined): ReadonlyArray<string> { | |
| 91 | if (!note) return []; | |
| 93 | interface ExtractedReleaseNoteItems { | |
| 94 | readonly items: ReadonlyArray<string>; | |
| 95 | readonly totalItems: number; | |
| 96 | } | |
| 97 | ||
| 98 | function extractReleaseNoteItems(note: string | null | undefined): ExtractedReleaseNoteItems { | |
| 99 | if (!note) return { items: [], totalItems: 0 }; | |
| 92 | 100 | |
| 93 | 101 | const items: string[] = []; |
| 102 | let totalItems = 0; | |
| 94 | 103 | for (const rawLine of stripMarkup(note).split("\n")) { |
| 95 | 104 | const item = rawLine |
| 96 | 105 | .trim() |
| 97 | 106 | .replace(/^[-*]\s+/, "") |
| 98 | 107 | .replace(/^\d+[.)]\s+/, "") |
| 99 | 108 | .replace(/\s+/g, " "); |
| 109 | const normalized = normalizeReleaseNoteLine(item); | |
| 110 | if (normalized === "new contributors" || normalized === "full changelog") break; | |
| 111 | if (/^#{1,6}\s+/.test(item)) continue; | |
| 100 | 112 | if (isIgnoredReleaseNoteLine(item)) continue; |
| 113 | totalItems += 1; | |
| 101 | 114 | items.push(truncateReleaseNoteItem(item)); |
| 102 | if (items.length >= MAX_RELEASE_NOTE_ITEMS_PER_GROUP) break; | |
| 115 | if (items.length > MAX_RELEASE_NOTE_ITEMS_PER_GROUP) items.shift(); | |
| 103 | 116 | } |
| 104 | return items; | |
| 117 | return { items: items.toReversed(), totalItems }; | |
| 118 | } | |
| 119 | ||
| 120 | interface NormalizedDesktopUpdateReleaseNotes { | |
| 121 | readonly releaseNotes: ReadonlyArray<DesktopUpdateReleaseNote>; | |
| 122 | readonly omittedReleaseCount: number; | |
| 105 | 123 | } |
| 106 | 124 | |
| 107 | 125 | export function normalizeDesktopUpdateReleaseNotes( |
| 108 | 126 | releaseNotes: unknown, |
| 109 | 127 | fallbackVersion: string, |
| 110 | ): ReadonlyArray<DesktopUpdateReleaseNote> { | |
| 128 | ): NormalizedDesktopUpdateReleaseNotes { | |
| 111 | 129 | const rawNotes = |
| 112 | 130 | typeof releaseNotes === "string" |
| 113 | 131 | ? [{ version: fallbackVersion, note: releaseNotes }] |
| ... | ... | @@ -115,11 +133,20 @@ export function normalizeDesktopUpdateReleaseNotes( |
| 115 | 133 | ? releaseNotes.filter(isElectronReleaseNoteInfo) |
| 116 | 134 | : []; |
| 117 | 135 | |
| 118 | return rawNotes | |
| 119 | .map((entry) => ({ | |
| 120 | version: entry.version, | |
| 121 | items: extractReleaseNoteItems(entry.note), | |
| 122 | })) | |
| 123 | .filter((entry) => entry.items.length > 0) | |
| 124 | .slice(0, MAX_RELEASE_NOTE_GROUPS); | |
| 136 | const normalizedNotes = rawNotes.flatMap((entry) => { | |
| 137 | const { items, totalItems } = extractReleaseNoteItems(entry.note); | |
| 138 | if (totalItems === 0) return []; | |
| 139 | return [ | |
| 140 | { | |
| 141 | version: entry.version, | |
| 142 | items, | |
| 143 | totalItems, | |
| 144 | }, | |
| 145 | ]; | |
| 146 | }); | |
| 147 | ||
| 148 | return { | |
| 149 | releaseNotes: normalizedNotes.slice(0, MAX_RELEASE_NOTE_GROUPS), | |
| 150 | omittedReleaseCount: Math.max(0, normalizedNotes.length - MAX_RELEASE_NOTE_GROUPS), | |
| 151 | }; | |
| 125 | 152 | } |
apps/desktop/src/updates/updateMachine.test.ts+19-5| ... | ... | @@ -62,7 +62,8 @@ describe("updateMachine", () => { |
| 62 | 62 | status: "downloaded" as const, |
| 63 | 63 | availableVersion: "1.1.0", |
| 64 | 64 | downloadedVersion: "1.1.0", |
| 65 | releaseNotes: [{ version: "1.1.0", items: ["fix: queued update"] }], | |
| 65 | releaseNotes: [{ version: "1.1.0", items: ["fix: queued update"], totalItems: 1 }], | |
| 66 | omittedReleaseCount: 2, | |
| 66 | 67 | downloadPercent: 100, |
| 67 | 68 | }; |
| 68 | 69 | const checking = reduceDesktopUpdateStateOnCheckStart( |
| ... | ... | @@ -78,14 +79,16 @@ describe("updateMachine", () => { |
| 78 | 79 | expect(checking.status).toBe("checking"); |
| 79 | 80 | expect(checking.downloadedVersion).toBe("1.1.0"); |
| 80 | 81 | expect(checking.releaseNotes).toEqual(downloadedState.releaseNotes); |
| 82 | expect(checking.omittedReleaseCount).toBe(2); | |
| 81 | 83 | expect(failed.status).toBe("downloaded"); |
| 82 | 84 | expect(failed.downloadedVersion).toBe("1.1.0"); |
| 83 | 85 | expect(failed.releaseNotes).toEqual(downloadedState.releaseNotes); |
| 86 | expect(failed.omittedReleaseCount).toBe(2); | |
| 84 | 87 | expect(failed.message).toBeNull(); |
| 85 | 88 | }); |
| 86 | 89 | |
| 87 | 90 | it("keeps the installer when the feed still offers its version", () => { |
| 88 | const releaseNotes = [{ version: "1.1.0", items: ["fix: queued update"] }]; | |
| 91 | const releaseNotes = [{ version: "1.1.0", items: ["fix: queued update"], totalItems: 1 }]; | |
| 89 | 92 | const state = reduceDesktopUpdateStateOnUpdateAvailable( |
| 90 | 93 | { |
| 91 | 94 | ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), |
| ... | ... | @@ -94,6 +97,7 @@ describe("updateMachine", () => { |
| 94 | 97 | availableVersion: "1.1.0", |
| 95 | 98 | downloadedVersion: "1.1.0", |
| 96 | 99 | releaseNotes, |
| 100 | omittedReleaseCount: 2, | |
| 97 | 101 | downloadPercent: 100, |
| 98 | 102 | }, |
| 99 | 103 | "1.1.0", |
| ... | ... | @@ -103,6 +107,7 @@ describe("updateMachine", () => { |
| 103 | 107 | expect(state.status).toBe("downloaded"); |
| 104 | 108 | expect(state.downloadedVersion).toBe("1.1.0"); |
| 105 | 109 | expect(state.releaseNotes).toEqual(releaseNotes); |
| 110 | expect(state.omittedReleaseCount).toBe(2); | |
| 106 | 111 | expect(state.downloadPercent).toBe(100); |
| 107 | 112 | }); |
| 108 | 113 | |
| ... | ... | @@ -147,7 +152,7 @@ describe("updateMachine", () => { |
| 147 | 152 | }); |
| 148 | 153 | |
| 149 | 154 | it("preserves a downloaded update when no update is available", () => { |
| 150 | const releaseNotes = [{ version: "1.1.0", items: ["fix: queued update"] }]; | |
| 155 | const releaseNotes = [{ version: "1.1.0", items: ["fix: queued update"], totalItems: 1 }]; | |
| 151 | 156 | const state = reduceDesktopUpdateStateOnNoUpdate( |
| 152 | 157 | { |
| 153 | 158 | ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), |
| ... | ... | @@ -156,6 +161,7 @@ describe("updateMachine", () => { |
| 156 | 161 | availableVersion: "1.1.0", |
| 157 | 162 | downloadedVersion: "1.1.0", |
| 158 | 163 | releaseNotes, |
| 164 | omittedReleaseCount: 2, | |
| 159 | 165 | message: "old failure", |
| 160 | 166 | errorContext: "download", |
| 161 | 167 | canRetry: true, |
| ... | ... | @@ -167,6 +173,7 @@ describe("updateMachine", () => { |
| 167 | 173 | expect(state.availableVersion).toBe("1.1.0"); |
| 168 | 174 | expect(state.downloadedVersion).toBe("1.1.0"); |
| 169 | 175 | expect(state.releaseNotes).toBe(releaseNotes); |
| 176 | expect(state.omittedReleaseCount).toBe(2); | |
| 170 | 177 | expect(state.downloadPercent).toBe(100); |
| 171 | 178 | expect(state.message).toBeNull(); |
| 172 | 179 | expect(state.errorContext).toBeNull(); |
| ... | ... | @@ -180,7 +187,8 @@ describe("updateMachine", () => { |
| 180 | 187 | enabled: true, |
| 181 | 188 | status: "error", |
| 182 | 189 | availableVersion: "1.1.0", |
| 183 | releaseNotes: [{ version: "1.1.0", items: ["fix: stale update"] }], | |
| 190 | releaseNotes: [{ version: "1.1.0", items: ["fix: stale update"], totalItems: 1 }], | |
| 191 | omittedReleaseCount: 2, | |
| 184 | 192 | message: "old failure", |
| 185 | 193 | errorContext: "download", |
| 186 | 194 | canRetry: true, |
| ... | ... | @@ -192,6 +200,7 @@ describe("updateMachine", () => { |
| 192 | 200 | expect(state.availableVersion).toBeNull(); |
| 193 | 201 | expect(state.downloadedVersion).toBeNull(); |
| 194 | 202 | expect(state.releaseNotes).toEqual([]); |
| 203 | expect(state.omittedReleaseCount).toBe(0); | |
| 195 | 204 | expect(state.message).toBeNull(); |
| 196 | 205 | expect(state.errorContext).toBeNull(); |
| 197 | 206 | }); |
| ... | ... | @@ -201,6 +210,7 @@ describe("updateMachine", () => { |
| 201 | 210 | { |
| 202 | 211 | version: "1.1.0", |
| 203 | 212 | items: ["feat: add update release notes"], |
| 213 | totalItems: 1, | |
| 204 | 214 | }, |
| 205 | 215 | ]; |
| 206 | 216 | const available = reduceDesktopUpdateStateOnUpdateAvailable( |
| ... | ... | @@ -212,6 +222,7 @@ describe("updateMachine", () => { |
| 212 | 222 | "1.1.0", |
| 213 | 223 | "2026-03-04T00:00:00.000Z", |
| 214 | 224 | releaseNotes, |
| 225 | 2, | |
| 215 | 226 | ); |
| 216 | 227 | const downloading = reduceDesktopUpdateStateOnDownloadStart(available); |
| 217 | 228 | const progress = reduceDesktopUpdateStateOnDownloadProgress(downloading, 55.5); |
| ... | ... | @@ -219,6 +230,7 @@ describe("updateMachine", () => { |
| 219 | 230 | expect(available.status).toBe("available"); |
| 220 | 231 | expect(available.channel).toBe("latest"); |
| 221 | 232 | expect(available.releaseNotes).toBe(releaseNotes); |
| 233 | expect(available.omittedReleaseCount).toBe(2); | |
| 222 | 234 | expect(downloading.releaseNotes).toBe(releaseNotes); |
| 223 | 235 | expect(downloading.status).toBe("downloading"); |
| 224 | 236 | expect(downloading.downloadPercent).toBe(0); |
| ... | ... | @@ -233,11 +245,13 @@ describe("updateMachine", () => { |
| 233 | 245 | enabled: true, |
| 234 | 246 | status: "available", |
| 235 | 247 | availableVersion: "1.1.0-nightly.1", |
| 236 | releaseNotes: [{ version: "1.1.0-nightly.1", items: ["feat: old note"] }], | |
| 248 | releaseNotes: [{ version: "1.1.0-nightly.1", items: ["feat: old note"], totalItems: 1 }], | |
| 249 | omittedReleaseCount: 2, | |
| 237 | 250 | }, |
| 238 | 251 | "2026-03-04T00:00:00.000Z", |
| 239 | 252 | ); |
| 240 | 253 | |
| 241 | 254 | expect(state.releaseNotes).toEqual([]); |
| 255 | expect(state.omittedReleaseCount).toBe(0); | |
| 242 | 256 | }); |
| 243 | 257 | }); |
apps/desktop/src/updates/updateMachine.ts+7-3| ... | ... | @@ -31,6 +31,7 @@ export function createInitialDesktopUpdateState( |
| 31 | 31 | availableVersion: null, |
| 32 | 32 | downloadedVersion: null, |
| 33 | 33 | releaseNotes: [], |
| 34 | omittedReleaseCount: 0, | |
| 34 | 35 | downloadPercent: null, |
| 35 | 36 | checkedAt: null, |
| 36 | 37 | message: null, |
| ... | ... | @@ -49,6 +50,7 @@ export function reduceDesktopUpdateStateOnCheckStart( |
| 49 | 50 | status: "checking", |
| 50 | 51 | checkedAt, |
| 51 | 52 | releaseNotes: hasDownloadedUpdate ? state.releaseNotes : [], |
| 53 | omittedReleaseCount: hasDownloadedUpdate ? state.omittedReleaseCount : 0, | |
| 52 | 54 | message: null, |
| 53 | 55 | downloadPercent: hasDownloadedUpdate ? 100 : null, |
| 54 | 56 | errorContext: null, |
| ... | ... | @@ -89,16 +91,17 @@ export function reduceDesktopUpdateStateOnUpdateAvailable( |
| 89 | 91 | version: string, |
| 90 | 92 | checkedAt: string, |
| 91 | 93 | releaseNotes: ReadonlyArray<DesktopUpdateReleaseNote> = [], |
| 94 | omittedReleaseCount = 0, | |
| 92 | 95 | ): DesktopUpdateState { |
| 93 | 96 | const isDownloadedVersion = state.downloadedVersion === version; |
| 94 | const nextReleaseNotes = | |
| 95 | isDownloadedVersion && releaseNotes.length === 0 ? state.releaseNotes : releaseNotes; | |
| 97 | const preserveReleaseNotes = isDownloadedVersion && releaseNotes.length === 0; | |
| 96 | 98 | return { |
| 97 | 99 | ...state, |
| 98 | 100 | status: isDownloadedVersion ? "downloaded" : "available", |
| 99 | 101 | availableVersion: version, |
| 100 | 102 | downloadedVersion: isDownloadedVersion ? version : null, |
| 101 | releaseNotes: nextReleaseNotes, | |
| 103 | releaseNotes: preserveReleaseNotes ? state.releaseNotes : releaseNotes, | |
| 104 | omittedReleaseCount: preserveReleaseNotes ? state.omittedReleaseCount : omittedReleaseCount, | |
| 102 | 105 | downloadPercent: isDownloadedVersion ? 100 : null, |
| 103 | 106 | checkedAt, |
| 104 | 107 | message: null, |
| ... | ... | @@ -130,6 +133,7 @@ export function reduceDesktopUpdateStateOnNoUpdate( |
| 130 | 133 | availableVersion: null, |
| 131 | 134 | downloadedVersion: null, |
| 132 | 135 | releaseNotes: [], |
| 136 | omittedReleaseCount: 0, | |
| 133 | 137 | downloadPercent: null, |
| 134 | 138 | checkedAt, |
| 135 | 139 | message: null, |
apps/web/src/components/desktopUpdate.logic.test.ts+8| ... | ... | @@ -7,6 +7,7 @@ import { |
| 7 | 7 | getDesktopUpdateActionError, |
| 8 | 8 | getDesktopUpdateButtonTooltip, |
| 9 | 9 | getDesktopUpdateInstallConfirmationMessage, |
| 10 | getDesktopUpdateReleaseHistoryUrl, | |
| 10 | 11 | getDesktopUpdateReleaseUrl, |
| 11 | 12 | isDesktopUpdateButtonDisabled, |
| 12 | 13 | resolveDesktopUpdateButtonAction, |
| ... | ... | @@ -26,6 +27,7 @@ const baseState: DesktopUpdateState = { |
| 26 | 27 | availableVersion: null, |
| 27 | 28 | downloadedVersion: null, |
| 28 | 29 | releaseNotes: [], |
| 30 | omittedReleaseCount: 0, | |
| 29 | 31 | downloadPercent: null, |
| 30 | 32 | checkedAt: null, |
| 31 | 33 | message: null, |
| ... | ... | @@ -202,6 +204,12 @@ describe("desktop update UI helpers", () => { |
| 202 | 204 | expect(getDesktopUpdateReleaseUrl(" ")).toBeNull(); |
| 203 | 205 | }); |
| 204 | 206 | |
| 207 | it("builds the release history URL", () => { | |
| 208 | expect(getDesktopUpdateReleaseHistoryUrl()).toBe( | |
| 209 | "https://github.com/pingdotgg/t3code/releases", | |
| 210 | ); | |
| 211 | }); | |
| 212 | ||
| 205 | 213 | it("toasts only for actionable updater errors", () => { |
| 206 | 214 | expect( |
| 207 | 215 | shouldToastDesktopUpdateActionResult({ |
apps/web/src/components/desktopUpdate.logic.ts+6-1| ... | ... | @@ -2,7 +2,8 @@ import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/con |
| 2 | 2 | |
| 3 | 3 | export type DesktopUpdateButtonAction = "download" | "install" | "none"; |
| 4 | 4 | |
| 5 | const DESKTOP_RELEASE_TAG_URL = "https://github.com/pingdotgg/t3code/releases/tag"; | |
| 5 | const DESKTOP_RELEASE_HISTORY_URL = "https://github.com/pingdotgg/t3code/releases"; | |
| 6 | const DESKTOP_RELEASE_TAG_URL = `${DESKTOP_RELEASE_HISTORY_URL}/tag`; | |
| 6 | 7 | |
| 7 | 8 | /** |
| 8 | 9 | * The main process fills `downloadedVersion` from the updater's `update-downloaded` |
| ... | ... | @@ -20,6 +21,10 @@ export function getDesktopUpdateReleaseUrl(version: string | null): string | nul |
| 20 | 21 | return `${DESKTOP_RELEASE_TAG_URL}/v${encodeURIComponent(normalizedVersion)}`; |
| 21 | 22 | } |
| 22 | 23 | |
| 24 | export function getDesktopUpdateReleaseHistoryUrl(): string { | |
| 25 | return DESKTOP_RELEASE_HISTORY_URL; | |
| 26 | } | |
| 27 | ||
| 23 | 28 | export function resolveDesktopUpdateButtonAction( |
| 24 | 29 | state: DesktopUpdateState, |
| 25 | 30 | ): DesktopUpdateButtonAction { |
apps/web/src/components/desktopUpdate.toast.test.tsx+1| ... | ... | @@ -50,6 +50,7 @@ function downloadedState(overrides: Partial<DesktopUpdateState> = {}): DesktopUp |
| 50 | 50 | availableVersion: "0.0.30", |
| 51 | 51 | downloadedVersion: "0.0.30", |
| 52 | 52 | releaseNotes: [], |
| 53 | omittedReleaseCount: 0, | |
| 53 | 54 | downloadPercent: 100, |
| 54 | 55 | checkedAt: null, |
| 55 | 56 | message: null, |
apps/web/src/components/desktopUpdate.toast.tsx+13-8| ... | ... | @@ -9,6 +9,18 @@ import { toastManager } from "./ui/toast"; |
| 9 | 9 | |
| 10 | 10 | type DesktopUpdateShell = Pick<DesktopBridge, "openExternal">; |
| 11 | 11 | |
| 12 | export async function openDesktopUpdateReleaseNotes( | |
| 13 | shell: DesktopUpdateShell | undefined, | |
| 14 | releaseUrl: string, | |
| 15 | ): Promise<void> { | |
| 16 | try { | |
| 17 | if (shell && (await shell.openExternal(releaseUrl))) return; | |
| 18 | } catch { | |
| 19 | // Surface rejected IPC calls through the same user-visible fallback. | |
| 20 | } | |
| 21 | toastManager.add({ type: "error", title: "Unable to open release notes" }); | |
| 22 | } | |
| 23 | ||
| 12 | 24 | function ReleaseNotesLink({ |
| 13 | 25 | shell, |
| 14 | 26 | releaseUrl, |
| ... | ... | @@ -20,14 +32,7 @@ function ReleaseNotesLink({ |
| 20 | 32 | <button |
| 21 | 33 | className="ml-2 inline cursor-pointer text-muted-foreground underline decoration-dotted underline-offset-4 transition-colors hover:text-foreground" |
| 22 | 34 | onClick={() => { |
| 23 | void (async () => { | |
| 24 | try { | |
| 25 | if (await shell.openExternal(releaseUrl)) return; | |
| 26 | } catch { | |
| 27 | // Surface rejected IPC calls through the same user-visible fallback. | |
| 28 | } | |
| 29 | toastManager.add({ type: "error", title: "Unable to open release notes" }); | |
| 30 | })(); | |
| 35 | void openDesktopUpdateReleaseNotes(shell, releaseUrl); | |
| 31 | 36 | }} |
| 32 | 37 | type="button" |
| 33 | 38 | > |
apps/web/src/components/sidebar/SidebarUpdatePill.test.tsx created+86| ... | ... | @@ -0,0 +1,86 @@ |
| 1 | import type { DesktopUpdateState } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { | |
| 5 | handleSidebarUpdateReleaseNotesPopoverOpenChange, | |
| 6 | openSidebarUpdateReleaseNotesPopoverOnForwardTab, | |
| 7 | shouldUseSidebarUpdateReleaseNotesPopover, | |
| 8 | } from "./SidebarUpdatePill"; | |
| 9 | ||
| 10 | const nightlyState: DesktopUpdateState = { | |
| 11 | enabled: true, | |
| 12 | status: "available", | |
| 13 | channel: "nightly", | |
| 14 | currentVersion: "0.0.35", | |
| 15 | hostArch: "arm64", | |
| 16 | appArch: "arm64", | |
| 17 | runningUnderArm64Translation: false, | |
| 18 | availableVersion: "0.0.36-nightly.3", | |
| 19 | downloadedVersion: null, | |
| 20 | releaseNotes: [{ version: "0.0.36-nightly.3", items: ["Newest change"], totalItems: 1 }], | |
| 21 | omittedReleaseCount: 0, | |
| 22 | downloadPercent: null, | |
| 23 | checkedAt: null, | |
| 24 | message: null, | |
| 25 | errorContext: null, | |
| 26 | canRetry: false, | |
| 27 | }; | |
| 28 | ||
| 29 | describe("sidebar update release notes popover", () => { | |
| 30 | it("uses the popover only for visible nightly release notes", () => { | |
| 31 | expect(shouldUseSidebarUpdateReleaseNotesPopover(true, nightlyState)).toBe(true); | |
| 32 | expect(shouldUseSidebarUpdateReleaseNotesPopover(false, nightlyState)).toBe(false); | |
| 33 | expect( | |
| 34 | shouldUseSidebarUpdateReleaseNotesPopover(true, { | |
| 35 | ...nightlyState, | |
| 36 | channel: "latest", | |
| 37 | }), | |
| 38 | ).toBe(false); | |
| 39 | expect( | |
| 40 | shouldUseSidebarUpdateReleaseNotesPopover(true, { | |
| 41 | ...nightlyState, | |
| 42 | releaseNotes: [], | |
| 43 | }), | |
| 44 | ).toBe(false); | |
| 45 | }); | |
| 46 | ||
| 47 | it("cancels trigger presses without canceling other open reasons", () => { | |
| 48 | const cancelTriggerPress = vi.fn(); | |
| 49 | const cancelHover = vi.fn(); | |
| 50 | ||
| 51 | handleSidebarUpdateReleaseNotesPopoverOpenChange(true, { | |
| 52 | reason: "trigger-press", | |
| 53 | cancel: cancelTriggerPress, | |
| 54 | }); | |
| 55 | handleSidebarUpdateReleaseNotesPopoverOpenChange(true, { | |
| 56 | reason: "trigger-hover", | |
| 57 | cancel: cancelHover, | |
| 58 | }); | |
| 59 | ||
| 60 | expect(cancelTriggerPress).toHaveBeenCalledOnce(); | |
| 61 | expect(cancelHover).not.toHaveBeenCalled(); | |
| 62 | }); | |
| 63 | ||
| 64 | it("promotes forward Tab without preventing native navigation", () => { | |
| 65 | const open = vi.fn(); | |
| 66 | const preventDefault = vi.fn(); | |
| 67 | const event = { key: "Tab", shiftKey: false, preventDefault }; | |
| 68 | ||
| 69 | openSidebarUpdateReleaseNotesPopoverOnForwardTab(event, { open }, "nightly-release-notes"); | |
| 70 | ||
| 71 | expect(open).toHaveBeenCalledWith("nightly-release-notes"); | |
| 72 | expect(preventDefault).not.toHaveBeenCalled(); | |
| 73 | }); | |
| 74 | ||
| 75 | it("does not promote backward Tab", () => { | |
| 76 | const open = vi.fn(); | |
| 77 | ||
| 78 | openSidebarUpdateReleaseNotesPopoverOnForwardTab( | |
| 79 | { key: "Tab", shiftKey: true }, | |
| 80 | { open }, | |
| 81 | "nightly-release-notes", | |
| 82 | ); | |
| 83 | ||
| 84 | expect(open).not.toHaveBeenCalled(); | |
| 85 | }); | |
| 86 | }); |
apps/web/src/components/sidebar/SidebarUpdatePill.tsx+170-115| ... | ... | @@ -1,5 +1,7 @@ |
| 1 | import type { DesktopUpdateState } from "@t3tools/contracts"; | |
| 1 | 2 | import { TriangleAlertIcon } from "lucide-react"; |
| 2 | import { useCallback, useEffect, useState } from "react"; | |
| 3 | import { type ComponentProps, useCallback, useEffect, useId, useRef, useState } from "react"; | |
| 4 | import { flushSync } from "react-dom"; | |
| 3 | 5 | import { isElectron } from "../../env"; |
| 4 | 6 | import { useMediaQuery } from "../../hooks/useMediaQuery"; |
| 5 | 7 | import { cn } from "../../lib/utils"; |
| ... | ... | @@ -19,7 +21,7 @@ import { |
| 19 | 21 | } from "../desktopUpdate.logic"; |
| 20 | 22 | import { showDesktopUpdateDownloadedToast } from "../desktopUpdate.toast"; |
| 21 | 23 | import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; |
| 22 | import { Separator } from "../ui/separator"; | |
| 24 | import { Popover, PopoverCreateHandle, PopoverPopup, PopoverTrigger } from "../ui/popover"; | |
| 23 | 25 | import { SidebarMenuItem } from "../ui/sidebar"; |
| 24 | 26 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 25 | 27 | import { |
| ... | ... | @@ -27,6 +29,37 @@ import { |
| 27 | 29 | shouldContinueDesktopUpdateCheckAnimation, |
| 28 | 30 | shouldShowDesktopUpdateCheckIcon, |
| 29 | 31 | } from "./DesktopUpdateStatusIcon"; |
| 32 | import { SidebarUpdateReleaseNotes } from "./SidebarUpdateReleaseNotes"; | |
| 33 | ||
| 34 | type SidebarUpdatePopoverChangeDetails = Parameters< | |
| 35 | NonNullable<ComponentProps<typeof Popover>["onOpenChange"]> | |
| 36 | >[1]; | |
| 37 | type SidebarUpdatePopoverHandle = ReturnType<typeof PopoverCreateHandle>; | |
| 38 | ||
| 39 | export function shouldUseSidebarUpdateReleaseNotesPopover( | |
| 40 | showUpdateDetails: boolean, | |
| 41 | state: DesktopUpdateState | null, | |
| 42 | ): boolean { | |
| 43 | return showUpdateDetails && state?.channel === "nightly" && state.releaseNotes.length > 0; | |
| 44 | } | |
| 45 | ||
| 46 | export function handleSidebarUpdateReleaseNotesPopoverOpenChange( | |
| 47 | _open: boolean, | |
| 48 | details: Pick<SidebarUpdatePopoverChangeDetails, "reason" | "cancel">, | |
| 49 | ): void { | |
| 50 | // The trigger is the update action, so its presses must not also toggle the Popover. | |
| 51 | if (details.reason === "trigger-press") details.cancel(); | |
| 52 | } | |
| 53 | ||
| 54 | export function openSidebarUpdateReleaseNotesPopoverOnForwardTab( | |
| 55 | event: { readonly key: string; readonly shiftKey: boolean }, | |
| 56 | handle: Pick<SidebarUpdatePopoverHandle, "open">, | |
| 57 | triggerId: string, | |
| 58 | ): void { | |
| 59 | if (event.key !== "Tab" || event.shiftKey) return; | |
| 60 | // Hover-open popovers do not manage focus. Promote this one before native Tab runs. | |
| 61 | flushSync(() => handle.open(triggerId)); | |
| 62 | } | |
| 30 | 63 | |
| 31 | 64 | function resolveSidebarUpdatePresentation({ |
| 32 | 65 | action, |
| ... | ... | @@ -55,67 +88,6 @@ function resolveSidebarUpdatePresentation({ |
| 55 | 88 | } as const; |
| 56 | 89 | } |
| 57 | 90 | |
| 58 | function keyReleaseNoteItems(items: ReadonlyArray<string>) { | |
| 59 | const occurrences = new Map<string, number>(); | |
| 60 | return items.map((item) => { | |
| 61 | const occurrence = occurrences.get(item) ?? 0; | |
| 62 | occurrences.set(item, occurrence + 1); | |
| 63 | return { item, key: JSON.stringify([item, occurrence]) }; | |
| 64 | }); | |
| 65 | } | |
| 66 | ||
| 67 | function SidebarUpdateReleaseNotesTooltip({ | |
| 68 | state, | |
| 69 | tooltip, | |
| 70 | }: { | |
| 71 | readonly state: NonNullable<ReturnType<typeof useDesktopUpdateState>>; | |
| 72 | readonly tooltip: string; | |
| 73 | }) { | |
| 74 | if (state.channel !== "nightly" || state.releaseNotes.length === 0) { | |
| 75 | return <>{tooltip}</>; | |
| 76 | } | |
| 77 | ||
| 78 | return ( | |
| 79 | <div className="w-fit max-w-[min(24rem,calc(100vw-2rem))] text-left"> | |
| 80 | <div className="px-1"> | |
| 81 | {state.status === "available" ? ( | |
| 82 | <div> | |
| 83 | <div className="whitespace-nowrap text-sm leading-5 font-medium"> | |
| 84 | Update ready to download | |
| 85 | </div> | |
| 86 | {state.availableVersion ? ( | |
| 87 | <div className="mt-0.5 text-xs leading-4 text-muted-foreground"> | |
| 88 | {state.availableVersion} | |
| 89 | </div> | |
| 90 | ) : null} | |
| 91 | </div> | |
| 92 | ) : ( | |
| 93 | <div className="text-sm leading-5 font-medium">{tooltip}</div> | |
| 94 | )} | |
| 95 | </div> | |
| 96 | <div className="max-h-[min(28rem,calc(100vh-6rem))] overflow-y-auto px-1 pt-4 pb-1"> | |
| 97 | {state.releaseNotes.map((releaseNote, index) => ( | |
| 98 | <div key={releaseNote.version}> | |
| 99 | {index > 0 && <Separator className="my-3 bg-border/60" />} | |
| 100 | <section> | |
| 101 | <h3 className="text-foreground text-xs leading-4 font-semibold"> | |
| 102 | {index === 0 ? "What's changed" : `Changes in ${releaseNote.version}`} | |
| 103 | </h3> | |
| 104 | <ul className="mt-2 space-y-1.5 pl-4 text-xs leading-5 text-popover-foreground/90"> | |
| 105 | {keyReleaseNoteItems(releaseNote.items).map(({ item, key }) => ( | |
| 106 | <li className="list-disc break-words" key={key}> | |
| 107 | {item} | |
| 108 | </li> | |
| 109 | ))} | |
| 110 | </ul> | |
| 111 | </section> | |
| 112 | </div> | |
| 113 | ))} | |
| 114 | </div> | |
| 115 | </div> | |
| 116 | ); | |
| 117 | } | |
| 118 | ||
| 119 | 91 | export function SidebarUpdateArchitectureWarning() { |
| 120 | 92 | return isElectron ? <SidebarUpdateArchitectureWarningContent /> : null; |
| 121 | 93 | } |
| ... | ... | @@ -145,6 +117,10 @@ function SidebarUpdateControl() { |
| 145 | 117 | const [isActionPending, setIsActionPending] = useState(false); |
| 146 | 118 | const [checkAnimationKey, setCheckAnimationKey] = useState(0); |
| 147 | 119 | const [isCheckAnimationLatched, setIsCheckAnimationLatched] = useState(false); |
| 120 | const [releaseNotesPopoverHandle] = useState(() => PopoverCreateHandle()); | |
| 121 | const suppressReleaseNotesFocusOpen = useRef(false); | |
| 122 | const releaseNotesPopupRef = useRef<HTMLDivElement>(null); | |
| 123 | const releaseNotesTriggerId = useId(); | |
| 148 | 124 | const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); |
| 149 | 125 | |
| 150 | 126 | useEffect(() => { |
| ... | ... | @@ -180,6 +156,22 @@ function SidebarUpdateControl() { |
| 180 | 156 | ? isDesktopUpdateButtonDisabled(state) |
| 181 | 157 | : !canCheckForUpdate(state); |
| 182 | 158 | const isInteractionDisabled = disabled || isActionPending; |
| 159 | const showReleaseNotesPopover = shouldUseSidebarUpdateReleaseNotesPopover( | |
| 160 | showUpdateDetails, | |
| 161 | state, | |
| 162 | ); | |
| 163 | ||
| 164 | useEffect(() => { | |
| 165 | if (!showReleaseNotesPopover) { | |
| 166 | releaseNotesPopoverHandle.close(); | |
| 167 | return; | |
| 168 | } | |
| 169 | ||
| 170 | const trigger = document.getElementById(releaseNotesTriggerId); | |
| 171 | if (trigger?.matches(":focus-visible")) { | |
| 172 | releaseNotesPopoverHandle.open(releaseNotesTriggerId); | |
| 173 | } | |
| 174 | }, [releaseNotesPopoverHandle, releaseNotesTriggerId, showReleaseNotesPopover]); | |
| 183 | 175 | |
| 184 | 176 | const handleAction = useCallback(async () => { |
| 185 | 177 | const bridge = window.desktopBridge; |
| ... | ... | @@ -305,61 +297,124 @@ function SidebarUpdateControl() { |
| 305 | 297 | ); |
| 306 | 298 | }, [prefersReducedMotion, state?.status]); |
| 307 | 299 | |
| 300 | const updateButton = ( | |
| 301 | <button | |
| 302 | type="button" | |
| 303 | aria-label={tooltip} | |
| 304 | aria-disabled={isInteractionDisabled || undefined} | |
| 305 | className={cn( | |
| 306 | "inline-flex size-8 items-center justify-center rounded-full outline-hidden ring-ring transition-colors focus-visible:ring-2", | |
| 307 | isInteractionDisabled ? "cursor-not-allowed" : "cursor-pointer", | |
| 308 | showUpdateIconState | |
| 309 | ? cn( | |
| 310 | "bg-sidebar-control-surface text-sidebar-foreground", | |
| 311 | !isInteractionDisabled && "hover:bg-sidebar-row-hover", | |
| 312 | ) | |
| 313 | : cn( | |
| 314 | "text-[var(--sidebar-icon-color)]", | |
| 315 | !isInteractionDisabled && "hover:bg-sidebar-row-hover hover:text-sidebar-foreground", | |
| 316 | ), | |
| 317 | disabled && !showUpdateIconState && "opacity-60", | |
| 318 | )} | |
| 319 | onClick={handleAction} | |
| 320 | onBlur={() => { | |
| 321 | suppressReleaseNotesFocusOpen.current = false; | |
| 322 | }} | |
| 323 | onFocus={(event) => { | |
| 324 | if (!showReleaseNotesPopover || !event.currentTarget.matches(":focus-visible")) return; | |
| 325 | if (suppressReleaseNotesFocusOpen.current) { | |
| 326 | suppressReleaseNotesFocusOpen.current = false; | |
| 327 | return; | |
| 328 | } | |
| 329 | flushSync(() => releaseNotesPopoverHandle.open(releaseNotesTriggerId)); | |
| 330 | }} | |
| 331 | onKeyDown={(event) => { | |
| 332 | if (!showReleaseNotesPopover) return; | |
| 333 | openSidebarUpdateReleaseNotesPopoverOnForwardTab( | |
| 334 | event, | |
| 335 | releaseNotesPopoverHandle, | |
| 336 | releaseNotesTriggerId, | |
| 337 | ); | |
| 338 | }} | |
| 339 | > | |
| 340 | <DesktopUpdateStatusIcon | |
| 341 | key={showCheckIcon ? checkAnimationKey : iconStatus} | |
| 342 | downloadPercent={state?.downloadPercent ?? null} | |
| 343 | isCheckAnimating={showCheckIcon && !prefersReducedMotion} | |
| 344 | onCheckAnimationIteration={handleCheckAnimationIteration} | |
| 345 | status={iconStatus} | |
| 346 | /> | |
| 347 | </button> | |
| 348 | ); | |
| 349 | ||
| 308 | 350 | return ( |
| 309 | 351 | <SidebarMenuItem className="ml-auto shrink-0"> |
| 310 | <Tooltip> | |
| 311 | <TooltipTrigger | |
| 312 | render={ | |
| 313 | <button | |
| 314 | type="button" | |
| 315 | aria-label={tooltip} | |
| 316 | aria-disabled={isInteractionDisabled || undefined} | |
| 317 | className={cn( | |
| 318 | "inline-flex size-8 items-center justify-center rounded-full outline-hidden ring-ring transition-colors focus-visible:ring-2", | |
| 319 | isInteractionDisabled ? "cursor-not-allowed" : "cursor-pointer", | |
| 320 | showUpdateIconState | |
| 321 | ? cn( | |
| 322 | "bg-sidebar-control-surface text-sidebar-foreground", | |
| 323 | !isInteractionDisabled && "hover:bg-sidebar-row-hover", | |
| 324 | ) | |
| 325 | : cn( | |
| 326 | "text-[var(--sidebar-icon-color)]", | |
| 327 | !isInteractionDisabled && | |
| 328 | "hover:bg-sidebar-row-hover hover:text-sidebar-foreground", | |
| 329 | ), | |
| 330 | disabled && !showUpdateIconState && "opacity-60", | |
| 331 | )} | |
| 332 | onClick={handleAction} | |
| 333 | > | |
| 334 | <DesktopUpdateStatusIcon | |
| 335 | key={showCheckIcon ? checkAnimationKey : iconStatus} | |
| 336 | downloadPercent={state?.downloadPercent ?? null} | |
| 337 | isCheckAnimating={showCheckIcon && !prefersReducedMotion} | |
| 338 | onCheckAnimationIteration={handleCheckAnimationIteration} | |
| 339 | status={iconStatus} | |
| 340 | /> | |
| 341 | </button> | |
| 342 | } | |
| 343 | /> | |
| 344 | <TooltipPopup | |
| 345 | align="center" | |
| 346 | className={ | |
| 347 | showUpdateDetails && state?.channel === "nightly" && state.releaseNotes.length > 0 | |
| 348 | ? // pointer-events-auto overrides the positioner's pointer-events-none so the | |
| 349 | // release notes stay open (and scrollable) when the cursor moves into them. | |
| 350 | "pointer-events-auto max-w-none text-balance" | |
| 351 | : undefined | |
| 352 | <Popover | |
| 353 | handle={releaseNotesPopoverHandle} | |
| 354 | onOpenChange={(open, details) => { | |
| 355 | if (open && !showReleaseNotesPopover) { | |
| 356 | details.cancel(); | |
| 357 | return; | |
| 352 | 358 | } |
| 353 | side="top" | |
| 354 | variant={showUpdateDetails ? "glass" : "default"} | |
| 355 | > | |
| 356 | {showUpdateDetails && state ? ( | |
| 357 | <SidebarUpdateReleaseNotesTooltip state={state} tooltip={tooltip} /> | |
| 358 | ) : ( | |
| 359 | tooltip | |
| 360 | )} | |
| 361 | </TooltipPopup> | |
| 362 | </Tooltip> | |
| 359 | handleSidebarUpdateReleaseNotesPopoverOpenChange(open, details); | |
| 360 | }} | |
| 361 | > | |
| 362 | <Tooltip disabled={showReleaseNotesPopover}> | |
| 363 | <TooltipTrigger | |
| 364 | id={releaseNotesTriggerId} | |
| 365 | render={ | |
| 366 | <PopoverTrigger | |
| 367 | {...(!showReleaseNotesPopover | |
| 368 | ? { | |
| 369 | "aria-controls": undefined, | |
| 370 | "aria-expanded": undefined, | |
| 371 | "aria-haspopup": undefined, | |
| 372 | } | |
| 373 | : {})} | |
| 374 | closeDelay={150} | |
| 375 | handle={releaseNotesPopoverHandle} | |
| 376 | id={releaseNotesTriggerId} | |
| 377 | openOnHover={showReleaseNotesPopover} | |
| 378 | render={updateButton} | |
| 379 | /> | |
| 380 | } | |
| 381 | /> | |
| 382 | {!showReleaseNotesPopover ? ( | |
| 383 | <TooltipPopup | |
| 384 | align="center" | |
| 385 | side="top" | |
| 386 | variant={showUpdateDetails ? "glass" : "default"} | |
| 387 | > | |
| 388 | {tooltip} | |
| 389 | </TooltipPopup> | |
| 390 | ) : null} | |
| 391 | </Tooltip> | |
| 392 | {showReleaseNotesPopover && state ? ( | |
| 393 | <PopoverPopup | |
| 394 | align="center" | |
| 395 | aria-label="Nightly update release notes" | |
| 396 | className="max-w-none text-balance shadow-xl shadow-black/25" | |
| 397 | initialFocus={false} | |
| 398 | onKeyDownCapture={(event) => { | |
| 399 | if ( | |
| 400 | event.key === "Escape" && | |
| 401 | releaseNotesPopupRef.current?.contains(document.activeElement) | |
| 402 | ) { | |
| 403 | suppressReleaseNotesFocusOpen.current = true; | |
| 404 | } | |
| 405 | }} | |
| 406 | ref={releaseNotesPopupRef} | |
| 407 | side="top" | |
| 408 | tooltipStyle | |
| 409 | > | |
| 410 | <SidebarUpdateReleaseNotes | |
| 411 | shell={window.desktopBridge} | |
| 412 | state={state} | |
| 413 | tooltip={tooltip} | |
| 414 | /> | |
| 415 | </PopoverPopup> | |
| 416 | ) : null} | |
| 417 | </Popover> | |
| 363 | 418 | </SidebarMenuItem> |
| 364 | 419 | ); |
| 365 | 420 | } |
apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.test.tsx created+152| ... | ... | @@ -0,0 +1,152 @@ |
| 1 | import type { DesktopUpdateState } from "@t3tools/contracts"; | |
| 2 | import { isValidElement, type MouseEvent, type ReactElement, type ReactNode } from "react"; | |
| 3 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 4 | ||
| 5 | const testState = vi.hoisted(() => ({ | |
| 6 | addToast: vi.fn(), | |
| 7 | })); | |
| 8 | ||
| 9 | vi.mock("../ui/toast", () => ({ | |
| 10 | toastManager: { add: testState.addToast }, | |
| 11 | })); | |
| 12 | ||
| 13 | import { SidebarUpdateReleaseNotes } from "./SidebarUpdateReleaseNotes"; | |
| 14 | ||
| 15 | type AnchorElement = ReactElement<{ | |
| 16 | readonly children?: ReactNode; | |
| 17 | readonly href?: string; | |
| 18 | readonly onClick?: (event: MouseEvent<HTMLAnchorElement>) => void; | |
| 19 | }>; | |
| 20 | ||
| 21 | const baseState: DesktopUpdateState = { | |
| 22 | enabled: true, | |
| 23 | status: "available", | |
| 24 | channel: "nightly", | |
| 25 | currentVersion: "0.0.35", | |
| 26 | hostArch: "arm64", | |
| 27 | appArch: "arm64", | |
| 28 | runningUnderArm64Translation: false, | |
| 29 | availableVersion: "0.0.36-nightly.3", | |
| 30 | downloadedVersion: null, | |
| 31 | releaseNotes: [], | |
| 32 | omittedReleaseCount: 0, | |
| 33 | downloadPercent: null, | |
| 34 | checkedAt: null, | |
| 35 | message: null, | |
| 36 | errorContext: null, | |
| 37 | canRetry: false, | |
| 38 | }; | |
| 39 | ||
| 40 | function collectAnchors(node: ReactNode, anchors: AnchorElement[] = []): AnchorElement[] { | |
| 41 | if (Array.isArray(node)) { | |
| 42 | for (const child of node) collectAnchors(child, anchors); | |
| 43 | return anchors; | |
| 44 | } | |
| 45 | if (!isValidElement(node)) return anchors; | |
| 46 | ||
| 47 | const element = node as ReactElement<{ readonly children?: ReactNode }>; | |
| 48 | if (typeof element.type === "function") { | |
| 49 | const render = element.type as (props: unknown) => ReactNode; | |
| 50 | return collectAnchors(render(element.props), anchors); | |
| 51 | } | |
| 52 | if (element.type === "a") anchors.push(element as AnchorElement); | |
| 53 | return collectAnchors(element.props.children, anchors); | |
| 54 | } | |
| 55 | ||
| 56 | function textContent(node: ReactNode): string { | |
| 57 | if (typeof node === "string" || typeof node === "number") return String(node); | |
| 58 | if (Array.isArray(node)) return node.map(textContent).join(""); | |
| 59 | if (!isValidElement(node)) return ""; | |
| 60 | const element = node as ReactElement<{ readonly children?: ReactNode }>; | |
| 61 | return textContent(element.props.children); | |
| 62 | } | |
| 63 | ||
| 64 | function renderNotes(state: DesktopUpdateState, openExternal = vi.fn().mockResolvedValue(true)) { | |
| 65 | return SidebarUpdateReleaseNotes({ | |
| 66 | shell: { openExternal }, | |
| 67 | state, | |
| 68 | tooltip: "Update available", | |
| 69 | }); | |
| 70 | } | |
| 71 | ||
| 72 | describe("SidebarUpdateReleaseNotes", () => { | |
| 73 | beforeEach(() => { | |
| 74 | testState.addToast.mockReset(); | |
| 75 | }); | |
| 76 | ||
| 77 | it("links each preview to its exact release and labels hidden changes", () => { | |
| 78 | const anchors = collectAnchors( | |
| 79 | renderNotes({ | |
| 80 | ...baseState, | |
| 81 | releaseNotes: [ | |
| 82 | { version: "0.0.36-nightly.3", items: ["Change 3"], totalItems: 1 }, | |
| 83 | { version: "0.0.36-nightly.2", items: ["Change 2"], totalItems: 2 }, | |
| 84 | { version: "0.0.36-nightly.1", items: ["Change 1", "Earlier"], totalItems: 4 }, | |
| 85 | ], | |
| 86 | }), | |
| 87 | ); | |
| 88 | ||
| 89 | expect(anchors.map(({ props }) => props.href)).toEqual([ | |
| 90 | "https://github.com/pingdotgg/t3code/releases/tag/v0.0.36-nightly.3", | |
| 91 | "https://github.com/pingdotgg/t3code/releases/tag/v0.0.36-nightly.2", | |
| 92 | "https://github.com/pingdotgg/t3code/releases/tag/v0.0.36-nightly.1", | |
| 93 | ]); | |
| 94 | expect(anchors.map(({ props }) => textContent(props.children))).toEqual([ | |
| 95 | "View release on GitHub", | |
| 96 | "1 more change on GitHub", | |
| 97 | "2 more changes on GitHub", | |
| 98 | ]); | |
| 99 | }); | |
| 100 | ||
| 101 | it("links omitted releases to release history", () => { | |
| 102 | const anchors = collectAnchors( | |
| 103 | renderNotes({ | |
| 104 | ...baseState, | |
| 105 | releaseNotes: [{ version: "0.0.36-nightly.3", items: ["Change 3"], totalItems: 1 }], | |
| 106 | omittedReleaseCount: 1, | |
| 107 | }), | |
| 108 | ); | |
| 109 | ||
| 110 | expect(anchors.at(-1)?.props.href).toBe("https://github.com/pingdotgg/t3code/releases"); | |
| 111 | expect(textContent(anchors.at(-1)?.props.children)).toBe("1 older release on GitHub"); | |
| 112 | }); | |
| 113 | ||
| 114 | it("shows plural history text for multiple omitted releases", () => { | |
| 115 | const anchors = collectAnchors( | |
| 116 | renderNotes({ | |
| 117 | ...baseState, | |
| 118 | releaseNotes: [{ version: "0.0.36-nightly.3", items: ["Change 3"], totalItems: 1 }], | |
| 119 | omittedReleaseCount: 3, | |
| 120 | }), | |
| 121 | ); | |
| 122 | ||
| 123 | expect(textContent(anchors.at(-1)?.props.children)).toBe("3 older releases on GitHub"); | |
| 124 | }); | |
| 125 | ||
| 126 | it("reports a release link that fails to open", async () => { | |
| 127 | const openExternal = vi.fn().mockResolvedValue(false); | |
| 128 | const [anchor] = collectAnchors( | |
| 129 | renderNotes( | |
| 130 | { | |
| 131 | ...baseState, | |
| 132 | releaseNotes: [{ version: "0.0.36-nightly.3", items: ["Change 3"], totalItems: 1 }], | |
| 133 | }, | |
| 134 | openExternal, | |
| 135 | ), | |
| 136 | ); | |
| 137 | const preventDefault = vi.fn(); | |
| 138 | ||
| 139 | anchor?.props.onClick?.({ preventDefault } as unknown as MouseEvent<HTMLAnchorElement>); | |
| 140 | ||
| 141 | expect(preventDefault).toHaveBeenCalledOnce(); | |
| 142 | await vi.waitFor(() => { | |
| 143 | expect(openExternal).toHaveBeenCalledWith( | |
| 144 | "https://github.com/pingdotgg/t3code/releases/tag/v0.0.36-nightly.3", | |
| 145 | ); | |
| 146 | expect(testState.addToast).toHaveBeenCalledWith({ | |
| 147 | type: "error", | |
| 148 | title: "Unable to open release notes", | |
| 149 | }); | |
| 150 | }); | |
| 151 | }); | |
| 152 | }); |
apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx created+120| ... | ... | @@ -0,0 +1,120 @@ |
| 1 | import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts"; | |
| 2 | import { ExternalLinkIcon } from "lucide-react"; | |
| 3 | ||
| 4 | import { | |
| 5 | getDesktopUpdateReleaseHistoryUrl, | |
| 6 | getDesktopUpdateReleaseUrl, | |
| 7 | } from "../desktopUpdate.logic"; | |
| 8 | import { openDesktopUpdateReleaseNotes } from "../desktopUpdate.toast"; | |
| 9 | import { Separator } from "../ui/separator"; | |
| 10 | ||
| 11 | type DesktopUpdateShell = Pick<DesktopBridge, "openExternal">; | |
| 12 | ||
| 13 | function keyReleaseNoteItems(items: ReadonlyArray<string>) { | |
| 14 | const occurrences = new Map<string, number>(); | |
| 15 | return items.map((item) => { | |
| 16 | const occurrence = occurrences.get(item) ?? 0; | |
| 17 | occurrences.set(item, occurrence + 1); | |
| 18 | return { item, key: JSON.stringify([item, occurrence]) }; | |
| 19 | }); | |
| 20 | } | |
| 21 | ||
| 22 | function ReleaseLink({ | |
| 23 | children, | |
| 24 | releaseUrl, | |
| 25 | shell, | |
| 26 | }: { | |
| 27 | readonly children: string; | |
| 28 | readonly releaseUrl: string; | |
| 29 | readonly shell: DesktopUpdateShell | undefined; | |
| 30 | }) { | |
| 31 | return ( | |
| 32 | <a | |
| 33 | className="mt-2 inline-flex items-center gap-1 rounded-sm text-xs leading-5 text-muted-foreground underline decoration-dotted underline-offset-4 outline-none transition-colors hover:text-foreground focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring" | |
| 34 | href={releaseUrl} | |
| 35 | onClick={(event) => { | |
| 36 | event.preventDefault(); | |
| 37 | void openDesktopUpdateReleaseNotes(shell, releaseUrl); | |
| 38 | }} | |
| 39 | > | |
| 40 | {children} | |
| 41 | <ExternalLinkIcon aria-hidden className="size-3 shrink-0" strokeWidth={2.25} /> | |
| 42 | </a> | |
| 43 | ); | |
| 44 | } | |
| 45 | ||
| 46 | export function SidebarUpdateReleaseNotes({ | |
| 47 | shell, | |
| 48 | state, | |
| 49 | tooltip, | |
| 50 | }: { | |
| 51 | readonly shell: DesktopUpdateShell | undefined; | |
| 52 | readonly state: DesktopUpdateState; | |
| 53 | readonly tooltip: string; | |
| 54 | }) { | |
| 55 | if (state.channel !== "nightly" || state.releaseNotes.length === 0) { | |
| 56 | return <>{tooltip}</>; | |
| 57 | } | |
| 58 | ||
| 59 | return ( | |
| 60 | <div className="flex max-h-[calc(var(--available-height)-0.5rem)] min-h-0 w-fit max-w-[min(24rem,calc(100vw-2rem))] flex-col text-left"> | |
| 61 | <div className="shrink-0 px-1"> | |
| 62 | {state.status === "available" ? ( | |
| 63 | <div> | |
| 64 | <div className="whitespace-nowrap text-sm leading-5 font-medium"> | |
| 65 | Update ready to download | |
| 66 | </div> | |
| 67 | {state.availableVersion ? ( | |
| 68 | <div className="mt-0.5 text-xs leading-4 text-muted-foreground"> | |
| 69 | {state.availableVersion} | |
| 70 | </div> | |
| 71 | ) : null} | |
| 72 | </div> | |
| 73 | ) : ( | |
| 74 | <div className="text-sm leading-5 font-medium">{tooltip}</div> | |
| 75 | )} | |
| 76 | </div> | |
| 77 | <div className="min-h-0 max-h-[min(28rem,calc(100vh-6rem))] overflow-y-auto px-1 pt-4 pb-1"> | |
| 78 | {state.releaseNotes.map((releaseNote, index) => { | |
| 79 | const releaseUrl = getDesktopUpdateReleaseUrl(releaseNote.version); | |
| 80 | const omittedItemCount = Math.max(0, releaseNote.totalItems - releaseNote.items.length); | |
| 81 | const linkLabel = | |
| 82 | omittedItemCount === 0 | |
| 83 | ? "View release on GitHub" | |
| 84 | : `${omittedItemCount} more ${omittedItemCount === 1 ? "change" : "changes"} on GitHub`; | |
| 85 | ||
| 86 | return ( | |
| 87 | <div key={releaseNote.version}> | |
| 88 | {index > 0 && <Separator className="my-3 bg-border/60" />} | |
| 89 | <section> | |
| 90 | <h3 className="text-foreground text-xs leading-4 font-semibold"> | |
| 91 | {index === 0 ? "What's changed" : `Changes in ${releaseNote.version}`} | |
| 92 | </h3> | |
| 93 | <ul className="mt-2 space-y-1.5 pl-4 text-xs leading-5 text-popover-foreground/90"> | |
| 94 | {keyReleaseNoteItems(releaseNote.items).map(({ item, key }) => ( | |
| 95 | <li className="list-disc break-words" key={key}> | |
| 96 | {item} | |
| 97 | </li> | |
| 98 | ))} | |
| 99 | </ul> | |
| 100 | {releaseUrl ? ( | |
| 101 | <ReleaseLink releaseUrl={releaseUrl} shell={shell}> | |
| 102 | {linkLabel} | |
| 103 | </ReleaseLink> | |
| 104 | ) : null} | |
| 105 | </section> | |
| 106 | </div> | |
| 107 | ); | |
| 108 | })} | |
| 109 | {state.omittedReleaseCount > 0 ? ( | |
| 110 | <div> | |
| 111 | <Separator className="my-3 bg-border/60" /> | |
| 112 | <ReleaseLink releaseUrl={getDesktopUpdateReleaseHistoryUrl()} shell={shell}> | |
| 113 | {`${state.omittedReleaseCount} older ${state.omittedReleaseCount === 1 ? "release" : "releases"} on GitHub`} | |
| 114 | </ReleaseLink> | |
| 115 | </div> | |
| 116 | ) : null} | |
| 117 | </div> | |
| 118 | </div> | |
| 119 | ); | |
| 120 | } |
apps/web/src/state/desktopUpdate.test.ts+1| ... | ... | @@ -16,6 +16,7 @@ const baseState: DesktopUpdateState = { |
| 16 | 16 | availableVersion: null, |
| 17 | 17 | downloadedVersion: null, |
| 18 | 18 | releaseNotes: [], |
| 19 | omittedReleaseCount: 0, | |
| 19 | 20 | downloadPercent: null, |
| 20 | 21 | checkedAt: null, |
| 21 | 22 | message: null, |
docs/user/updating.md+10| ... | ... | @@ -57,6 +57,16 @@ the warning always works. |
| 57 | 57 | See [Running T3 Code in the Background](./background-service.md) for install, status, and removal |
| 58 | 58 | commands. |
| 59 | 59 | |
| 60 | ## Nightly desktop release notes | |
| 61 | ||
| 62 | The desktop app shows a compact release-notes preview when a nightly update is available. Changes | |
| 63 | appear newest first within each release. Each release links to its exact page on GitHub, even when | |
| 64 | all changes fit in the preview. | |
| 65 | ||
| 66 | The preview shows up to eight changes from each of six releases. When it leaves out changes or older | |
| 67 | releases, it shows the exact number and links to the rest. Contributor credits do not count as | |
| 68 | changes. | |
| 69 | ||
| 60 | 70 | ## After the Update |
| 61 | 71 | |
| 62 | 72 | Keep the web or desktop app open while the server restarts. The update completes only after the |
packages/contracts/src/ipc.ts+4| ... | ... | @@ -209,6 +209,7 @@ export interface DesktopUpdateState { |
| 209 | 209 | availableVersion: string | null; |
| 210 | 210 | downloadedVersion: string | null; |
| 211 | 211 | releaseNotes: ReadonlyArray<DesktopUpdateReleaseNote>; |
| 212 | omittedReleaseCount: number; | |
| 212 | 213 | downloadPercent: number | null; |
| 213 | 214 | checkedAt: string | null; |
| 214 | 215 | message: string | null; |
| ... | ... | @@ -219,11 +220,13 @@ export interface DesktopUpdateState { |
| 219 | 220 | export interface DesktopUpdateReleaseNote { |
| 220 | 221 | version: string; |
| 221 | 222 | items: ReadonlyArray<string>; |
| 223 | totalItems: number; | |
| 222 | 224 | } |
| 223 | 225 | |
| 224 | 226 | export const DesktopUpdateReleaseNoteSchema = Schema.Struct({ |
| 225 | 227 | version: Schema.String, |
| 226 | 228 | items: Schema.Array(Schema.String), |
| 229 | totalItems: Schema.Number, | |
| 227 | 230 | }); |
| 228 | 231 | |
| 229 | 232 | export const DesktopUpdateStateSchema = Schema.Struct({ |
| ... | ... | @@ -237,6 +240,7 @@ export const DesktopUpdateStateSchema = Schema.Struct({ |
| 237 | 240 | availableVersion: Schema.NullOr(Schema.String), |
| 238 | 241 | downloadedVersion: Schema.NullOr(Schema.String), |
| 239 | 242 | releaseNotes: Schema.Array(DesktopUpdateReleaseNoteSchema), |
| 243 | omittedReleaseCount: Schema.Number, | |
| 240 | 244 | downloadPercent: Schema.NullOr(Schema.Number), |
| 241 | 245 | checkedAt: Schema.NullOr(Schema.String), |
| 242 | 246 | message: Schema.NullOr(Schema.String), |