| 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,6 +336,11 @@ describe("DesktopUpdates", () => { |
| 336 | version: "1.2.4-nightly.20260709.765", | 336 | version: "1.2.4-nightly.20260709.765", |
| 337 | note: "- [codex] Upgrade Clerk stack by @juliusmarminge in #3821", | 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 | yield* flushCallbacks; | 346 | yield* flushCallbacks; |
| ... | @@ -346,13 +351,21 @@ describe("DesktopUpdates", () => { | ... | @@ -346,13 +351,21 @@ describe("DesktopUpdates", () => { |
| 346 | { | 351 | { |
| 347 | version: "1.2.4-nightly.20260709.766", | 352 | version: "1.2.4-nightly.20260709.766", |
| 348 | items: ["feat(client): persist offline environment data by @juliusmarminge in #3795"], | 353 | items: ["feat(client): persist offline environment data by @juliusmarminge in #3795"], |
| 354 | totalItems: 1, | ||
| 349 | }, | 355 | }, |
| 350 | { | 356 | { |
| 351 | version: "1.2.4-nightly.20260709.765", | 357 | version: "1.2.4-nightly.20260709.765", |
| 352 | items: ["[codex] Upgrade Clerk stack by @juliusmarminge in #3821"], | 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 | assert.deepEqual(harness.sentStates.at(-1)?.releaseNotes, state.releaseNotes); | 367 | assert.deepEqual(harness.sentStates.at(-1)?.releaseNotes, state.releaseNotes); |
| 368 | assert.equal(harness.sentStates.at(-1)?.omittedReleaseCount, 1); | ||
| 356 | }), | 369 | }), |
| 357 | ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); | 370 | ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); |
| 358 | }); | 371 | }); |
| ... | @@ -383,8 +396,9 @@ describe("DesktopUpdates", () => { | ... | @@ -383,8 +396,9 @@ describe("DesktopUpdates", () => { |
| 383 | assert.equal(unchangedState.status, "downloaded"); | 396 | assert.equal(unchangedState.status, "downloaded"); |
| 384 | assert.equal(unchangedState.downloadedVersion, "1.2.4"); | 397 | assert.equal(unchangedState.downloadedVersion, "1.2.4"); |
| 385 | assert.deepEqual(unchangedState.releaseNotes, [ | 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 | const nextResult = yield* updates.check("poll"); | 403 | const nextResult = yield* updates.check("poll"); |
| 390 | assert.isTrue(nextResult.checked); | 404 | assert.isTrue(nextResult.checked); |
| ... | @@ -424,7 +438,10 @@ describe("DesktopUpdates", () => { | ... | @@ -424,7 +438,10 @@ describe("DesktopUpdates", () => { |
| 424 | assert.equal(state.status, "downloaded"); | 438 | assert.equal(state.status, "downloaded"); |
| 425 | assert.equal(state.availableVersion, "1.2.4"); | 439 | assert.equal(state.availableVersion, "1.2.4"); |
| 426 | assert.equal(state.downloadedVersion, "1.2.4"); | 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 | assert.equal(state.downloadPercent, 100); | 445 | assert.equal(state.downloadPercent, 100); |
| 429 | }), | 446 | }), |
| 430 | ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); | 447 | ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); |
| ... | @@ -454,7 +471,10 @@ describe("DesktopUpdates", () => { | ... | @@ -454,7 +471,10 @@ describe("DesktopUpdates", () => { |
| 454 | assert.equal(state.status, "downloaded"); | 471 | assert.equal(state.status, "downloaded"); |
| 455 | assert.equal(state.availableVersion, "1.2.4"); | 472 | assert.equal(state.availableVersion, "1.2.4"); |
| 456 | assert.equal(state.downloadedVersion, "1.2.4"); | 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 | assert.equal(state.downloadPercent, 100); | 478 | assert.equal(state.downloadPercent, 100); |
| 459 | }), | 479 | }), |
| 460 | ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); | 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,14 +593,24 @@ export const make = Effect.gen(function* () { |
| 593 | } | 593 | } |
| 594 | 594 | ||
| 595 | const checkedAt = yield* currentIsoTimestamp; | 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 | yield* setState( | 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 | yield* Ref.set(lastLoggedDownloadMilestoneRef, -1); | 609 | yield* Ref.set(lastLoggedDownloadMilestoneRef, -1); |
| 601 | yield* logUpdaterInfo("update available", { | 610 | yield* logUpdaterInfo("update available", { |
| 602 | version: info.version, | 611 | version: info.version, |
| 603 | releaseNoteGroups: releaseNotes.length, | 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,38 +3,137 @@ import { describe, expect, it } from "vite-plus/test"; |
| 3 | import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts"; | 3 | import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts"; |
| 4 | 4 | ||
| 5 | describe("normalizeDesktopUpdateReleaseNotes", () => { | 5 | describe("normalizeDesktopUpdateReleaseNotes", () => { |
| 6 | it("splits a plain string note into items under the fallback version", () => { | 6 | it("shows the newest changes and counts all real changes", () => { |
| 7 | const notes = normalizeDesktopUpdateReleaseNotes( | 7 | const result = normalizeDesktopUpdateReleaseNotes( |
| 8 | "## What's changed\n- First fix\n- Second fix", | 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 | "1.2.3", | 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", () => { | 80 | it("keeps per-version order and drops empty groups", () => { |
| 15 | const notes = normalizeDesktopUpdateReleaseNotes( | 81 | const result = normalizeDesktopUpdateReleaseNotes( |
| 16 | [ | 82 | [ |
| 17 | { version: "1.2.3", note: "- Newer change" }, | 83 | { version: "1.2.3", note: "- Newer release" }, |
| 18 | { version: "1.2.2", note: "Full changelog: https://example.com/compare/x...y" }, | 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 | "1.2.3", | 87 | "1.2.3", |
| 22 | ); | 88 | ); |
| 23 | expect(notes).toEqual([ | 89 | |
| 24 | { version: "1.2.3", items: ["Newer change"] }, | 90 | expect(result).toEqual({ |
| 25 | { version: "1.2.1", items: ["Older change"] }, | 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 | it("decodes valid HTML entities", () => { | 124 | it("decodes valid HTML entities", () => { |
| 30 | const notes = normalizeDesktopUpdateReleaseNotes("- Fix &amp; polish &#128512;", "1.0.0"); | 125 | const result = normalizeDesktopUpdateReleaseNotes("- Fix &amp; polish &#128512;", "1.0.0"); |
| 31 | expect(notes).toEqual([{ version: "1.0.0", items: ["Fix & polish 😀"] }]); | 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", () => { | 132 | it("ignores malformed and empty entries instead of throwing", () => { |
| 35 | const notes = normalizeDesktopUpdateReleaseNotes( | 133 | const result = normalizeDesktopUpdateReleaseNotes( |
| 36 | [ | 134 | [ |
| 37 | { version: "1.2.3", note: "- Valid change" }, | 135 | { version: "1.2.3", note: "- Valid change" }, |
| 136 | { version: "1.2.2", note: "" }, | ||
| 38 | { version: 42, note: "- Bad version type" }, | 137 | { version: 42, note: "- Bad version type" }, |
| 39 | { version: "1.2.1", note: { html: "<p>object note</p>" } }, | 138 | { version: "1.2.1", note: { html: "<p>object note</p>" } }, |
| 40 | "not an object", | 139 | "not an object", |
| ... | @@ -42,23 +141,25 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { | ... | @@ -42,23 +141,25 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { |
| 42 | ], | 141 | ], |
| 43 | "1.2.3", | 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", () => { | 151 | it("returns an empty result for an invalid payload", () => { |
| 49 | const boilerplate = Array.from({ length: 7 }, (_, index) => ({ | 152 | expect(normalizeDesktopUpdateReleaseNotes({ note: "- Invalid" }, "1.0.0")).toEqual({ |
| 50 | version: `1.3.${9 - index}`, | 153 | releaseNotes: [], |
| 51 | note: "Full changelog: https://example.com/compare/x...y", | 154 | omittedReleaseCount: 0, |
| 52 | })); | 155 | }); |
| 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"] }]); | ||
| 58 | }); | 156 | }); |
| 59 | 157 | ||
| 60 | it("does not throw on out-of-range numeric entities and keeps the literal", () => { | 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"); | 159 | const result = normalizeDesktopUpdateReleaseNotes("- Broken entity &#9999999999;", "1.0.0"); |
| 62 | expect(notes).toEqual([{ version: "1.0.0", items: ["Broken entity &#9999999999;"] }]); | 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,6 +59,7 @@ function stripMarkup(input: string): string { |
| 59 | input | 59 | input |
| 60 | .replace(/<br\s*\/?>/gi, "\n") | 60 | .replace(/<br\s*\/?>/gi, "\n") |
| 61 | .replace(/<li\b[^>]*>/gi, "\n- ") | 61 | .replace(/<li\b[^>]*>/gi, "\n- ") |
| 62 | .replace(/<h([1-6])\b[^>]*>/gi, (_, level: string) => `\n${"#".repeat(Number(level))} `) | ||
| 62 | .replace(/<\/(?:p|div|li|h[1-6]|ul|ol|blockquote)>/gi, "\n") | 63 | .replace(/<\/(?:p|div|li|h[1-6]|ul|ol|blockquote)>/gi, "\n") |
| 63 | .replace(/<[^>]*>/g, "") | 64 | .replace(/<[^>]*>/g, "") |
| 64 | .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") | 65 | .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") |
| ... | @@ -71,43 +72,60 @@ function truncateReleaseNoteItem(item: string): string { | ... | @@ -71,43 +72,60 @@ function truncateReleaseNoteItem(item: string): string { |
| 71 | return `${item.slice(0, MAX_RELEASE_NOTE_ITEM_LENGTH - 3).trimEnd()}...`; | 72 | return `${item.slice(0, MAX_RELEASE_NOTE_ITEM_LENGTH - 3).trimEnd()}...`; |
| 72 | } | 73 | } |
| 73 | 74 | ||
| 74 | function isIgnoredReleaseNoteLine(line: string): boolean { | 75 | function normalizeReleaseNoteLine(line: string): string { |
| 75 | const normalized = line | 76 | return line |
| 76 | .toLowerCase() | 77 | .toLowerCase() |
| 77 | .replace(/[*_`#]/g, "") | 78 | .replace(/[*_`#]/g, "") |
| 78 | .trim(); | 79 | .trim(); |
| 80 | } | ||
| 81 | |||
| 82 | function isIgnoredReleaseNoteLine(line: string): boolean { | ||
| 83 | const normalized = normalizeReleaseNoteLine(line); | ||
| 79 | return ( | 84 | return ( |
| 80 | normalized === "" || | 85 | normalized === "" || |
| 81 | normalized === "what's changed" || | 86 | normalized === "what's changed" || |
| 82 | normalized === "whats changed" || | 87 | normalized === "whats changed" || |
| 83 | normalized === "full changelog" || | ||
| 84 | normalized === "new contributors" || | ||
| 85 | normalized.startsWith("compare: ") || | 88 | normalized.startsWith("compare: ") || |
| 86 | normalized.includes("/compare/") | 89 | normalized.includes("/compare/") |
| 87 | ); | 90 | ); |
| 88 | } | 91 | } |
| 89 | 92 | ||
| 90 | function extractReleaseNoteItems(note: string | null | undefined): ReadonlyArray<string> { | 93 | interface ExtractedReleaseNoteItems { |
| 91 | if (!note) return []; | 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 | const items: string[] = []; | 101 | const items: string[] = []; |
| 102 | let totalItems = 0; | ||
| 94 | for (const rawLine of stripMarkup(note).split("\n")) { | 103 | for (const rawLine of stripMarkup(note).split("\n")) { |
| 95 | const item = rawLine | 104 | const item = rawLine |
| 96 | .trim() | 105 | .trim() |
| 97 | .replace(/^[-*]\s+/, "") | 106 | .replace(/^[-*]\s+/, "") |
| 98 | .replace(/^\d+[.)]\s+/, "") | 107 | .replace(/^\d+[.)]\s+/, "") |
| 99 | .replace(/\s+/g, " "); | 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 | if (isIgnoredReleaseNoteLine(item)) continue; | 112 | if (isIgnoredReleaseNoteLine(item)) continue; |
| 113 | totalItems += 1; | ||
| 101 | items.push(truncateReleaseNoteItem(item)); | 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 | export function normalizeDesktopUpdateReleaseNotes( | 125 | export function normalizeDesktopUpdateReleaseNotes( |
| 108 | releaseNotes: unknown, | 126 | releaseNotes: unknown, |
| 109 | fallbackVersion: string, | 127 | fallbackVersion: string, |
| 110 | ): ReadonlyArray<DesktopUpdateReleaseNote> { | 128 | ): NormalizedDesktopUpdateReleaseNotes { |
| 111 | const rawNotes = | 129 | const rawNotes = |
| 112 | typeof releaseNotes === "string" | 130 | typeof releaseNotes === "string" |
| 113 | ? [{ version: fallbackVersion, note: releaseNotes }] | 131 | ? [{ version: fallbackVersion, note: releaseNotes }] |
| ... | @@ -115,11 +133,20 @@ export function normalizeDesktopUpdateReleaseNotes( | ... | @@ -115,11 +133,20 @@ export function normalizeDesktopUpdateReleaseNotes( |
| 115 | ? releaseNotes.filter(isElectronReleaseNoteInfo) | 133 | ? releaseNotes.filter(isElectronReleaseNoteInfo) |
| 116 | : []; | 134 | : []; |
| 117 | 135 | ||
| 118 | return rawNotes | 136 | const normalizedNotes = rawNotes.flatMap((entry) => { |
| 119 | .map((entry) => ({ | 137 | const { items, totalItems } = extractReleaseNoteItems(entry.note); |
| 120 | version: entry.version, | 138 | if (totalItems === 0) return []; |
| 121 | items: extractReleaseNoteItems(entry.note), | 139 | return [ |
| 122 | })) | 140 | { |
| 123 | .filter((entry) => entry.items.length > 0) | 141 | version: entry.version, |
| 124 | .slice(0, MAX_RELEASE_NOTE_GROUPS); | 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,7 +62,8 @@ describe("updateMachine", () => { |
| 62 | status: "downloaded" as const, | 62 | status: "downloaded" as const, |
| 63 | availableVersion: "1.1.0", | 63 | availableVersion: "1.1.0", |
| 64 | downloadedVersion: "1.1.0", | 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 | downloadPercent: 100, | 67 | downloadPercent: 100, |
| 67 | }; | 68 | }; |
| 68 | const checking = reduceDesktopUpdateStateOnCheckStart( | 69 | const checking = reduceDesktopUpdateStateOnCheckStart( |
| ... | @@ -78,14 +79,16 @@ describe("updateMachine", () => { | ... | @@ -78,14 +79,16 @@ describe("updateMachine", () => { |
| 78 | expect(checking.status).toBe("checking"); | 79 | expect(checking.status).toBe("checking"); |
| 79 | expect(checking.downloadedVersion).toBe("1.1.0"); | 80 | expect(checking.downloadedVersion).toBe("1.1.0"); |
| 80 | expect(checking.releaseNotes).toEqual(downloadedState.releaseNotes); | 81 | expect(checking.releaseNotes).toEqual(downloadedState.releaseNotes); |
| 82 | expect(checking.omittedReleaseCount).toBe(2); | ||
| 81 | expect(failed.status).toBe("downloaded"); | 83 | expect(failed.status).toBe("downloaded"); |
| 82 | expect(failed.downloadedVersion).toBe("1.1.0"); | 84 | expect(failed.downloadedVersion).toBe("1.1.0"); |
| 83 | expect(failed.releaseNotes).toEqual(downloadedState.releaseNotes); | 85 | expect(failed.releaseNotes).toEqual(downloadedState.releaseNotes); |
| 86 | expect(failed.omittedReleaseCount).toBe(2); | ||
| 84 | expect(failed.message).toBeNull(); | 87 | expect(failed.message).toBeNull(); |
| 85 | }); | 88 | }); |
| 86 | 89 | ||
| 87 | it("keeps the installer when the feed still offers its version", () => { | 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 | const state = reduceDesktopUpdateStateOnUpdateAvailable( | 92 | const state = reduceDesktopUpdateStateOnUpdateAvailable( |
| 90 | { | 93 | { |
| 91 | ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), | 94 | ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), |
| ... | @@ -94,6 +97,7 @@ describe("updateMachine", () => { | ... | @@ -94,6 +97,7 @@ describe("updateMachine", () => { |
| 94 | availableVersion: "1.1.0", | 97 | availableVersion: "1.1.0", |
| 95 | downloadedVersion: "1.1.0", | 98 | downloadedVersion: "1.1.0", |
| 96 | releaseNotes, | 99 | releaseNotes, |
| 100 | omittedReleaseCount: 2, | ||
| 97 | downloadPercent: 100, | 101 | downloadPercent: 100, |
| 98 | }, | 102 | }, |
| 99 | "1.1.0", | 103 | "1.1.0", |
| ... | @@ -103,6 +107,7 @@ describe("updateMachine", () => { | ... | @@ -103,6 +107,7 @@ describe("updateMachine", () => { |
| 103 | expect(state.status).toBe("downloaded"); | 107 | expect(state.status).toBe("downloaded"); |
| 104 | expect(state.downloadedVersion).toBe("1.1.0"); | 108 | expect(state.downloadedVersion).toBe("1.1.0"); |
| 105 | expect(state.releaseNotes).toEqual(releaseNotes); | 109 | expect(state.releaseNotes).toEqual(releaseNotes); |
| 110 | expect(state.omittedReleaseCount).toBe(2); | ||
| 106 | expect(state.downloadPercent).toBe(100); | 111 | expect(state.downloadPercent).toBe(100); |
| 107 | }); | 112 | }); |
| 108 | 113 | ||
| ... | @@ -147,7 +152,7 @@ describe("updateMachine", () => { | ... | @@ -147,7 +152,7 @@ describe("updateMachine", () => { |
| 147 | }); | 152 | }); |
| 148 | 153 | ||
| 149 | it("preserves a downloaded update when no update is available", () => { | 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 | const state = reduceDesktopUpdateStateOnNoUpdate( | 156 | const state = reduceDesktopUpdateStateOnNoUpdate( |
| 152 | { | 157 | { |
| 153 | ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), | 158 | ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), |
| ... | @@ -156,6 +161,7 @@ describe("updateMachine", () => { | ... | @@ -156,6 +161,7 @@ describe("updateMachine", () => { |
| 156 | availableVersion: "1.1.0", | 161 | availableVersion: "1.1.0", |
| 157 | downloadedVersion: "1.1.0", | 162 | downloadedVersion: "1.1.0", |
| 158 | releaseNotes, | 163 | releaseNotes, |
| 164 | omittedReleaseCount: 2, | ||
| 159 | message: "old failure", | 165 | message: "old failure", |
| 160 | errorContext: "download", | 166 | errorContext: "download", |
| 161 | canRetry: true, | 167 | canRetry: true, |
| ... | @@ -167,6 +173,7 @@ describe("updateMachine", () => { | ... | @@ -167,6 +173,7 @@ describe("updateMachine", () => { |
| 167 | expect(state.availableVersion).toBe("1.1.0"); | 173 | expect(state.availableVersion).toBe("1.1.0"); |
| 168 | expect(state.downloadedVersion).toBe("1.1.0"); | 174 | expect(state.downloadedVersion).toBe("1.1.0"); |
| 169 | expect(state.releaseNotes).toBe(releaseNotes); | 175 | expect(state.releaseNotes).toBe(releaseNotes); |
| 176 | expect(state.omittedReleaseCount).toBe(2); | ||
| 170 | expect(state.downloadPercent).toBe(100); | 177 | expect(state.downloadPercent).toBe(100); |
| 171 | expect(state.message).toBeNull(); | 178 | expect(state.message).toBeNull(); |
| 172 | expect(state.errorContext).toBeNull(); | 179 | expect(state.errorContext).toBeNull(); |
| ... | @@ -180,7 +187,8 @@ describe("updateMachine", () => { | ... | @@ -180,7 +187,8 @@ describe("updateMachine", () => { |
| 180 | enabled: true, | 187 | enabled: true, |
| 181 | status: "error", | 188 | status: "error", |
| 182 | availableVersion: "1.1.0", | 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 | message: "old failure", | 192 | message: "old failure", |
| 185 | errorContext: "download", | 193 | errorContext: "download", |
| 186 | canRetry: true, | 194 | canRetry: true, |
| ... | @@ -192,6 +200,7 @@ describe("updateMachine", () => { | ... | @@ -192,6 +200,7 @@ describe("updateMachine", () => { |
| 192 | expect(state.availableVersion).toBeNull(); | 200 | expect(state.availableVersion).toBeNull(); |
| 193 | expect(state.downloadedVersion).toBeNull(); | 201 | expect(state.downloadedVersion).toBeNull(); |
| 194 | expect(state.releaseNotes).toEqual([]); | 202 | expect(state.releaseNotes).toEqual([]); |
| 203 | expect(state.omittedReleaseCount).toBe(0); | ||
| 195 | expect(state.message).toBeNull(); | 204 | expect(state.message).toBeNull(); |
| 196 | expect(state.errorContext).toBeNull(); | 205 | expect(state.errorContext).toBeNull(); |
| 197 | }); | 206 | }); |
| ... | @@ -201,6 +210,7 @@ describe("updateMachine", () => { | ... | @@ -201,6 +210,7 @@ describe("updateMachine", () => { |
| 201 | { | 210 | { |
| 202 | version: "1.1.0", | 211 | version: "1.1.0", |
| 203 | items: ["feat: add update release notes"], | 212 | items: ["feat: add update release notes"], |
| 213 | totalItems: 1, | ||
| 204 | }, | 214 | }, |
| 205 | ]; | 215 | ]; |
| 206 | const available = reduceDesktopUpdateStateOnUpdateAvailable( | 216 | const available = reduceDesktopUpdateStateOnUpdateAvailable( |
| ... | @@ -212,6 +222,7 @@ describe("updateMachine", () => { | ... | @@ -212,6 +222,7 @@ describe("updateMachine", () => { |
| 212 | "1.1.0", | 222 | "1.1.0", |
| 213 | "2026-03-04T00:00:00.000Z", | 223 | "2026-03-04T00:00:00.000Z", |
| 214 | releaseNotes, | 224 | releaseNotes, |
| 225 | 2, | ||
| 215 | ); | 226 | ); |
| 216 | const downloading = reduceDesktopUpdateStateOnDownloadStart(available); | 227 | const downloading = reduceDesktopUpdateStateOnDownloadStart(available); |
| 217 | const progress = reduceDesktopUpdateStateOnDownloadProgress(downloading, 55.5); | 228 | const progress = reduceDesktopUpdateStateOnDownloadProgress(downloading, 55.5); |
| ... | @@ -219,6 +230,7 @@ describe("updateMachine", () => { | ... | @@ -219,6 +230,7 @@ describe("updateMachine", () => { |
| 219 | expect(available.status).toBe("available"); | 230 | expect(available.status).toBe("available"); |
| 220 | expect(available.channel).toBe("latest"); | 231 | expect(available.channel).toBe("latest"); |
| 221 | expect(available.releaseNotes).toBe(releaseNotes); | 232 | expect(available.releaseNotes).toBe(releaseNotes); |
| 233 | expect(available.omittedReleaseCount).toBe(2); | ||
| 222 | expect(downloading.releaseNotes).toBe(releaseNotes); | 234 | expect(downloading.releaseNotes).toBe(releaseNotes); |
| 223 | expect(downloading.status).toBe("downloading"); | 235 | expect(downloading.status).toBe("downloading"); |
| 224 | expect(downloading.downloadPercent).toBe(0); | 236 | expect(downloading.downloadPercent).toBe(0); |
| ... | @@ -233,11 +245,13 @@ describe("updateMachine", () => { | ... | @@ -233,11 +245,13 @@ describe("updateMachine", () => { |
| 233 | enabled: true, | 245 | enabled: true, |
| 234 | status: "available", | 246 | status: "available", |
| 235 | availableVersion: "1.1.0-nightly.1", | 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 | "2026-03-04T00:00:00.000Z", | 251 | "2026-03-04T00:00:00.000Z", |
| 239 | ); | 252 | ); |
| 240 | 253 | ||
| 241 | expect(state.releaseNotes).toEqual([]); | 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,6 +31,7 @@ export function createInitialDesktopUpdateState( |
| 31 | availableVersion: null, | 31 | availableVersion: null, |
| 32 | downloadedVersion: null, | 32 | downloadedVersion: null, |
| 33 | releaseNotes: [], | 33 | releaseNotes: [], |
| 34 | omittedReleaseCount: 0, | ||
| 34 | downloadPercent: null, | 35 | downloadPercent: null, |
| 35 | checkedAt: null, | 36 | checkedAt: null, |
| 36 | message: null, | 37 | message: null, |
| ... | @@ -49,6 +50,7 @@ export function reduceDesktopUpdateStateOnCheckStart( | ... | @@ -49,6 +50,7 @@ export function reduceDesktopUpdateStateOnCheckStart( |
| 49 | status: "checking", | 50 | status: "checking", |
| 50 | checkedAt, | 51 | checkedAt, |
| 51 | releaseNotes: hasDownloadedUpdate ? state.releaseNotes : [], | 52 | releaseNotes: hasDownloadedUpdate ? state.releaseNotes : [], |
| 53 | omittedReleaseCount: hasDownloadedUpdate ? state.omittedReleaseCount : 0, | ||
| 52 | message: null, | 54 | message: null, |
| 53 | downloadPercent: hasDownloadedUpdate ? 100 : null, | 55 | downloadPercent: hasDownloadedUpdate ? 100 : null, |
| 54 | errorContext: null, | 56 | errorContext: null, |
| ... | @@ -89,16 +91,17 @@ export function reduceDesktopUpdateStateOnUpdateAvailable( | ... | @@ -89,16 +91,17 @@ export function reduceDesktopUpdateStateOnUpdateAvailable( |
| 89 | version: string, | 91 | version: string, |
| 90 | checkedAt: string, | 92 | checkedAt: string, |
| 91 | releaseNotes: ReadonlyArray<DesktopUpdateReleaseNote> = [], | 93 | releaseNotes: ReadonlyArray<DesktopUpdateReleaseNote> = [], |
| 94 | omittedReleaseCount = 0, | ||
| 92 | ): DesktopUpdateState { | 95 | ): DesktopUpdateState { |
| 93 | const isDownloadedVersion = state.downloadedVersion === version; | 96 | const isDownloadedVersion = state.downloadedVersion === version; |
| 94 | const nextReleaseNotes = | 97 | const preserveReleaseNotes = isDownloadedVersion && releaseNotes.length === 0; |
| 95 | isDownloadedVersion && releaseNotes.length === 0 ? state.releaseNotes : releaseNotes; | ||
| 96 | return { | 98 | return { |
| 97 | ...state, | 99 | ...state, |
| 98 | status: isDownloadedVersion ? "downloaded" : "available", | 100 | status: isDownloadedVersion ? "downloaded" : "available", |
| 99 | availableVersion: version, | 101 | availableVersion: version, |
| 100 | downloadedVersion: isDownloadedVersion ? version : null, | 102 | downloadedVersion: isDownloadedVersion ? version : null, |
| 101 | releaseNotes: nextReleaseNotes, | 103 | releaseNotes: preserveReleaseNotes ? state.releaseNotes : releaseNotes, |
| 104 | omittedReleaseCount: preserveReleaseNotes ? state.omittedReleaseCount : omittedReleaseCount, | ||
| 102 | downloadPercent: isDownloadedVersion ? 100 : null, | 105 | downloadPercent: isDownloadedVersion ? 100 : null, |
| 103 | checkedAt, | 106 | checkedAt, |
| 104 | message: null, | 107 | message: null, |
| ... | @@ -130,6 +133,7 @@ export function reduceDesktopUpdateStateOnNoUpdate( | ... | @@ -130,6 +133,7 @@ export function reduceDesktopUpdateStateOnNoUpdate( |
| 130 | availableVersion: null, | 133 | availableVersion: null, |
| 131 | downloadedVersion: null, | 134 | downloadedVersion: null, |
| 132 | releaseNotes: [], | 135 | releaseNotes: [], |
| 136 | omittedReleaseCount: 0, | ||
| 133 | downloadPercent: null, | 137 | downloadPercent: null, |
| 134 | checkedAt, | 138 | checkedAt, |
| 135 | message: null, | 139 | message: null, |
apps/web/src/components/desktopUpdate.logic.test.ts+8| ... | @@ -7,6 +7,7 @@ import { | ... | @@ -7,6 +7,7 @@ import { |
| 7 | getDesktopUpdateActionError, | 7 | getDesktopUpdateActionError, |
| 8 | getDesktopUpdateButtonTooltip, | 8 | getDesktopUpdateButtonTooltip, |
| 9 | getDesktopUpdateInstallConfirmationMessage, | 9 | getDesktopUpdateInstallConfirmationMessage, |
| 10 | getDesktopUpdateReleaseHistoryUrl, | ||
| 10 | getDesktopUpdateReleaseUrl, | 11 | getDesktopUpdateReleaseUrl, |
| 11 | isDesktopUpdateButtonDisabled, | 12 | isDesktopUpdateButtonDisabled, |
| 12 | resolveDesktopUpdateButtonAction, | 13 | resolveDesktopUpdateButtonAction, |
| ... | @@ -26,6 +27,7 @@ const baseState: DesktopUpdateState = { | ... | @@ -26,6 +27,7 @@ const baseState: DesktopUpdateState = { |
| 26 | availableVersion: null, | 27 | availableVersion: null, |
| 27 | downloadedVersion: null, | 28 | downloadedVersion: null, |
| 28 | releaseNotes: [], | 29 | releaseNotes: [], |
| 30 | omittedReleaseCount: 0, | ||
| 29 | downloadPercent: null, | 31 | downloadPercent: null, |
| 30 | checkedAt: null, | 32 | checkedAt: null, |
| 31 | message: null, | 33 | message: null, |
| ... | @@ -202,6 +204,12 @@ describe("desktop update UI helpers", () => { | ... | @@ -202,6 +204,12 @@ describe("desktop update UI helpers", () => { |
| 202 | expect(getDesktopUpdateReleaseUrl(" ")).toBeNull(); | 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 | it("toasts only for actionable updater errors", () => { | 213 | it("toasts only for actionable updater errors", () => { |
| 206 | expect( | 214 | expect( |
| 207 | shouldToastDesktopUpdateActionResult({ | 215 | shouldToastDesktopUpdateActionResult({ |
apps/web/src/components/desktopUpdate.logic.ts+6-1| ... | @@ -2,7 +2,8 @@ import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/con | ... | @@ -2,7 +2,8 @@ import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/con |
| 2 | 2 | ||
| 3 | export type DesktopUpdateButtonAction = "download" | "install" | "none"; | 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 | * The main process fills `downloadedVersion` from the updater's `update-downloaded` | 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,6 +21,10 @@ export function getDesktopUpdateReleaseUrl(version: string | null): string | nul |
| 20 | return `${DESKTOP_RELEASE_TAG_URL}/v${encodeURIComponent(normalizedVersion)}`; | 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 | export function resolveDesktopUpdateButtonAction( | 28 | export function resolveDesktopUpdateButtonAction( |
| 24 | state: DesktopUpdateState, | 29 | state: DesktopUpdateState, |
| 25 | ): DesktopUpdateButtonAction { | 30 | ): DesktopUpdateButtonAction { |
apps/web/src/components/desktopUpdate.toast.test.tsx+1| ... | @@ -50,6 +50,7 @@ function downloadedState(overrides: Partial<DesktopUpdateState> = {}): DesktopUp | ... | @@ -50,6 +50,7 @@ function downloadedState(overrides: Partial<DesktopUpdateState> = {}): DesktopUp |
| 50 | availableVersion: "0.0.30", | 50 | availableVersion: "0.0.30", |
| 51 | downloadedVersion: "0.0.30", | 51 | downloadedVersion: "0.0.30", |
| 52 | releaseNotes: [], | 52 | releaseNotes: [], |
| 53 | omittedReleaseCount: 0, | ||
| 53 | downloadPercent: 100, | 54 | downloadPercent: 100, |
| 54 | checkedAt: null, | 55 | checkedAt: null, |
| 55 | message: null, | 56 | message: null, |
apps/web/src/components/desktopUpdate.toast.tsx+13-8| ... | @@ -9,6 +9,18 @@ import { toastManager } from "./ui/toast"; | ... | @@ -9,6 +9,18 @@ import { toastManager } from "./ui/toast"; |
| 9 | 9 | ||
| 10 | type DesktopUpdateShell = Pick<DesktopBridge, "openExternal">; | 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 | function ReleaseNotesLink({ | 24 | function ReleaseNotesLink({ |
| 13 | shell, | 25 | shell, |
| 14 | releaseUrl, | 26 | releaseUrl, |
| ... | @@ -20,14 +32,7 @@ function ReleaseNotesLink({ | ... | @@ -20,14 +32,7 @@ function ReleaseNotesLink({ |
| 20 | <button | 32 | <button |
| 21 | className="ml-2 inline cursor-pointer text-muted-foreground underline decoration-dotted underline-offset-4 transition-colors hover:text-foreground" | 33 | className="ml-2 inline cursor-pointer text-muted-foreground underline decoration-dotted underline-offset-4 transition-colors hover:text-foreground" |
| 22 | onClick={() => { | 34 | onClick={() => { |
| 23 | void (async () => { | 35 | void openDesktopUpdateReleaseNotes(shell, releaseUrl); |
| 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 | })(); | ||
| 31 | }} | 36 | }} |
| 32 | type="button" | 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,5 +1,7 @@ |
| 1 | import type { DesktopUpdateState } from "@t3tools/contracts"; | ||
| 1 | import { TriangleAlertIcon } from "lucide-react"; | 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 | import { isElectron } from "../../env"; | 5 | import { isElectron } from "../../env"; |
| 4 | import { useMediaQuery } from "../../hooks/useMediaQuery"; | 6 | import { useMediaQuery } from "../../hooks/useMediaQuery"; |
| 5 | import { cn } from "../../lib/utils"; | 7 | import { cn } from "../../lib/utils"; |
| ... | @@ -19,7 +21,7 @@ import { | ... | @@ -19,7 +21,7 @@ import { |
| 19 | } from "../desktopUpdate.logic"; | 21 | } from "../desktopUpdate.logic"; |
| 20 | import { showDesktopUpdateDownloadedToast } from "../desktopUpdate.toast"; | 22 | import { showDesktopUpdateDownloadedToast } from "../desktopUpdate.toast"; |
| 21 | import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; | 23 | import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; |
| 22 | import { Separator } from "../ui/separator"; | 24 | import { Popover, PopoverCreateHandle, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 23 | import { SidebarMenuItem } from "../ui/sidebar"; | 25 | import { SidebarMenuItem } from "../ui/sidebar"; |
| 24 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 26 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 25 | import { | 27 | import { |
| ... | @@ -27,6 +29,37 @@ import { | ... | @@ -27,6 +29,37 @@ import { |
| 27 | shouldContinueDesktopUpdateCheckAnimation, | 29 | shouldContinueDesktopUpdateCheckAnimation, |
| 28 | shouldShowDesktopUpdateCheckIcon, | 30 | shouldShowDesktopUpdateCheckIcon, |
| 29 | } from "./DesktopUpdateStatusIcon"; | 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 | function resolveSidebarUpdatePresentation({ | 64 | function resolveSidebarUpdatePresentation({ |
| 32 | action, | 65 | action, |
| ... | @@ -55,67 +88,6 @@ function resolveSidebarUpdatePresentation({ | ... | @@ -55,67 +88,6 @@ function resolveSidebarUpdatePresentation({ |
| 55 | } as const; | 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 | export function SidebarUpdateArchitectureWarning() { | 91 | export function SidebarUpdateArchitectureWarning() { |
| 120 | return isElectron ? <SidebarUpdateArchitectureWarningContent /> : null; | 92 | return isElectron ? <SidebarUpdateArchitectureWarningContent /> : null; |
| 121 | } | 93 | } |
| ... | @@ -145,6 +117,10 @@ function SidebarUpdateControl() { | ... | @@ -145,6 +117,10 @@ function SidebarUpdateControl() { |
| 145 | const [isActionPending, setIsActionPending] = useState(false); | 117 | const [isActionPending, setIsActionPending] = useState(false); |
| 146 | const [checkAnimationKey, setCheckAnimationKey] = useState(0); | 118 | const [checkAnimationKey, setCheckAnimationKey] = useState(0); |
| 147 | const [isCheckAnimationLatched, setIsCheckAnimationLatched] = useState(false); | 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 | const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); | 124 | const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); |
| 149 | 125 | ||
| 150 | useEffect(() => { | 126 | useEffect(() => { |
| ... | @@ -180,6 +156,22 @@ function SidebarUpdateControl() { | ... | @@ -180,6 +156,22 @@ function SidebarUpdateControl() { |
| 180 | ? isDesktopUpdateButtonDisabled(state) | 156 | ? isDesktopUpdateButtonDisabled(state) |
| 181 | : !canCheckForUpdate(state); | 157 | : !canCheckForUpdate(state); |
| 182 | const isInteractionDisabled = disabled || isActionPending; | 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 | const handleAction = useCallback(async () => { | 176 | const handleAction = useCallback(async () => { |
| 185 | const bridge = window.desktopBridge; | 177 | const bridge = window.desktopBridge; |
| ... | @@ -305,61 +297,124 @@ function SidebarUpdateControl() { | ... | @@ -305,61 +297,124 @@ function SidebarUpdateControl() { |
| 305 | ); | 297 | ); |
| 306 | }, [prefersReducedMotion, state?.status]); | 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 | return ( | 350 | return ( |
| 309 | <SidebarMenuItem className="ml-auto shrink-0"> | 351 | <SidebarMenuItem className="ml-auto shrink-0"> |
| 310 | <Tooltip> | 352 | <Popover |
| 311 | <TooltipTrigger | 353 | handle={releaseNotesPopoverHandle} |
| 312 | render={ | 354 | onOpenChange={(open, details) => { |
| 313 | <button | 355 | if (open && !showReleaseNotesPopover) { |
| 314 | type="button" | 356 | details.cancel(); |
| 315 | aria-label={tooltip} | 357 | return; |
| 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 | } | 358 | } |
| 353 | side="top" | 359 | handleSidebarUpdateReleaseNotesPopoverOpenChange(open, details); |
| 354 | variant={showUpdateDetails ? "glass" : "default"} | 360 | }} |
| 355 | > | 361 | > |
| 356 | {showUpdateDetails && state ? ( | 362 | <Tooltip disabled={showReleaseNotesPopover}> |
| 357 | <SidebarUpdateReleaseNotesTooltip state={state} tooltip={tooltip} /> | 363 | <TooltipTrigger |
| 358 | ) : ( | 364 | id={releaseNotesTriggerId} |
| 359 | tooltip | 365 | render={ |
| 360 | )} | 366 | <PopoverTrigger |
| 361 | </TooltipPopup> | 367 | {...(!showReleaseNotesPopover |
| 362 | </Tooltip> | 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 | </SidebarMenuItem> | 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,6 +16,7 @@ const baseState: DesktopUpdateState = { |
| 16 | availableVersion: null, | 16 | availableVersion: null, |
| 17 | downloadedVersion: null, | 17 | downloadedVersion: null, |
| 18 | releaseNotes: [], | 18 | releaseNotes: [], |
| 19 | omittedReleaseCount: 0, | ||
| 19 | downloadPercent: null, | 20 | downloadPercent: null, |
| 20 | checkedAt: null, | 21 | checkedAt: null, |
| 21 | message: null, | 22 | message: null, |
docs/user/updating.md+10| ... | @@ -57,6 +57,16 @@ the warning always works. | ... | @@ -57,6 +57,16 @@ the warning always works. |
| 57 | See [Running T3 Code in the Background](./background-service.md) for install, status, and removal | 57 | See [Running T3 Code in the Background](./background-service.md) for install, status, and removal |
| 58 | commands. | 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 | ## After the Update | 70 | ## After the Update |
| 61 | 71 | ||
| 62 | Keep the web or desktop app open while the server restarts. The update completes only after the | 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,6 +209,7 @@ export interface DesktopUpdateState { |
| 209 | availableVersion: string | null; | 209 | availableVersion: string | null; |
| 210 | downloadedVersion: string | null; | 210 | downloadedVersion: string | null; |
| 211 | releaseNotes: ReadonlyArray<DesktopUpdateReleaseNote>; | 211 | releaseNotes: ReadonlyArray<DesktopUpdateReleaseNote>; |
| 212 | omittedReleaseCount: number; | ||
| 212 | downloadPercent: number | null; | 213 | downloadPercent: number | null; |
| 213 | checkedAt: string | null; | 214 | checkedAt: string | null; |
| 214 | message: string | null; | 215 | message: string | null; |
| ... | @@ -219,11 +220,13 @@ export interface DesktopUpdateState { | ... | @@ -219,11 +220,13 @@ export interface DesktopUpdateState { |
| 219 | export interface DesktopUpdateReleaseNote { | 220 | export interface DesktopUpdateReleaseNote { |
| 220 | version: string; | 221 | version: string; |
| 221 | items: ReadonlyArray<string>; | 222 | items: ReadonlyArray<string>; |
| 223 | totalItems: number; | ||
| 222 | } | 224 | } |
| 223 | 225 | ||
| 224 | export const DesktopUpdateReleaseNoteSchema = Schema.Struct({ | 226 | export const DesktopUpdateReleaseNoteSchema = Schema.Struct({ |
| 225 | version: Schema.String, | 227 | version: Schema.String, |
| 226 | items: Schema.Array(Schema.String), | 228 | items: Schema.Array(Schema.String), |
| 229 | totalItems: Schema.Number, | ||
| 227 | }); | 230 | }); |
| 228 | 231 | ||
| 229 | export const DesktopUpdateStateSchema = Schema.Struct({ | 232 | export const DesktopUpdateStateSchema = Schema.Struct({ |
| ... | @@ -237,6 +240,7 @@ export const DesktopUpdateStateSchema = Schema.Struct({ | ... | @@ -237,6 +240,7 @@ export const DesktopUpdateStateSchema = Schema.Struct({ |
| 237 | availableVersion: Schema.NullOr(Schema.String), | 240 | availableVersion: Schema.NullOr(Schema.String), |
| 238 | downloadedVersion: Schema.NullOr(Schema.String), | 241 | downloadedVersion: Schema.NullOr(Schema.String), |
| 239 | releaseNotes: Schema.Array(DesktopUpdateReleaseNoteSchema), | 242 | releaseNotes: Schema.Array(DesktopUpdateReleaseNoteSchema), |
| 243 | omittedReleaseCount: Schema.Number, | ||
| 240 | downloadPercent: Schema.NullOr(Schema.Number), | 244 | downloadPercent: Schema.NullOr(Schema.Number), |
| 241 | checkedAt: Schema.NullOr(Schema.String), | 245 | checkedAt: Schema.NullOr(Schema.String), |
| 242 | message: Schema.NullOr(Schema.String), | 246 | message: Schema.NullOr(Schema.String), |