From 6eac53772b5a1351726b0c1a00b278d4db69d8a7 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 11 Nov 2026 11:40:39 -0600 Subject: [PATCH] chore: remove beta what's new and visual svgs (#63325) --- .github/pull_request_template.md | 7 - .github/workflows/release.yml | 9 - AGENTS.md | 2 - bun.lock | 1 - packages/app/src/components-lab/lab.tsx | 6 +- packages/desktop/README.md | 5 - packages/desktop/electron.vite.config.ts | 14 +- packages/desktop/package.json | 1 - packages/desktop/script/beta-notes.test.ts | 298 ----------------- packages/desktop/script/beta-notes.ts | 313 ------------------ packages/desktop/script/beta-visual.ts | 278 ---------------- packages/gui-plugins/src/env.d.ts | 6 - .../src/updater/beta-dialog.stories.tsx | 205 ------------ .../gui-plugins/src/updater/beta-dialog.tsx | 237 ------------- .../src/updater/beta-notes.test.ts | 135 -------- .../gui-plugins/src/updater/beta-notes.ts | 133 -------- .../gui-plugins/src/updater/beta-page.tsx | 140 -------- packages/gui-plugins/src/updater/i18n/en.ts | 9 - packages/gui-plugins/src/updater/index.ts | 5 +- packages/gui-plugins/src/updater/renderer.tsx | 59 +--- .../src/updater/whats-new-machine.test.ts | 60 +--- .../src/updater/whats-new-machine.ts | 44 +-- 22 files changed, 29 insertions(+), 1938 deletions(-) delete mode 100644 packages/desktop/script/beta-notes.test.ts delete mode 100644 packages/desktop/script/beta-notes.ts delete mode 100644 packages/desktop/script/beta-visual.ts delete mode 100644 packages/gui-plugins/src/env.d.ts delete mode 100644 packages/gui-plugins/src/updater/beta-dialog.stories.tsx delete mode 100644 packages/gui-plugins/src/updater/beta-dialog.tsx delete mode 100644 packages/gui-plugins/src/updater/beta-notes.test.ts delete mode 100644 packages/gui-plugins/src/updater/beta-notes.ts delete mode 100644 packages/gui-plugins/src/updater/beta-page.tsx diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index a323ca5a6b3..393bf90518e 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -17,13 +17,6 @@ Please provide a description of the issue, the changes you made to fix it, and w ### How did you verify your code works? -### Visual - -_Optional, for user-facing changes: a small looping animated SVG (240×72) of the change, shown in the desktop app's "What's new in beta". Replace the block below or delete this section._ - -```svg -``` - ### Screenshots / recordings _If this is a UI change, please include a screenshot or recording._ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 68ee4e4f783..0d21fcfbc84 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -318,7 +318,6 @@ jobs: if: needs.plan.outputs.desktop == 'true' permissions: contents: read - pull-requests: read # beta builds read who made each change and what it does for "What's new in beta" strategy: fail-fast: false matrix: @@ -346,13 +345,6 @@ jobs: - name: Set up rpm if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y --no-install-recommends rpm - # The beta build embeds the subjects of its newest commits for "What's new in beta" - # (packages/desktop/script/beta-notes.ts). Commits without file contents are enough. - - name: Fetch beta history - if: needs.plan.outputs.channel == 'beta' - run: git fetch --no-tags --filter=blob:none --depth=300 origin "$SHA" - env: - SHA: ${{ needs.tag.outputs.sha }} # Dev and beta trade larger packages for faster packaging. fpm sets XZ_OPT for the deb and rpm level, but xz # still reads XZ_DEFAULTS, so it uses every CPU. - name: Preview packaging settings @@ -367,7 +359,6 @@ jobs: VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }} VITE_SENTRY_ENVIRONMENT: ${{ needs.plan.outputs.channel == 'latest' && 'production' || needs.plan.outputs.channel }} VITE_SENTRY_RELEASE: desktop@${{ needs.plan.outputs.version }} - GITHUB_TOKEN: ${{ github.token }} # read-only; beta notes read pull request authors and descriptions - name: Pack working-directory: packages/desktop timeout-minutes: 60 diff --git a/AGENTS.md b/AGENTS.md index ebfc4fd6d9a..15f27a31245 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,8 +44,6 @@ Valid types are `feat`, `fix`, `docs`, `chore`, `refactor`, and `test`. Scopes a Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributing guide`, `chore(sdk): regenerate types`. -For user-facing `feat`, `fix` and `perf` PRs, add a small looping animated SVG of the change under `### Visual` in the PR body (a `svg` code block, 240×72, for example a dock growing out of the composer or spinners turning in step). Draw with `currentColor` and opacity so it works in light and dark, make the unanimated state a good still (it is shown under reduced motion), animate with CSS or SMIL only, and keep it under 8 KB with no scripts or external references. The desktop beta's "What's new" dialog shows it. - ## Style Guide ### General Principles diff --git a/bun.lock b/bun.lock index a8fb016d7c2..f68bcb266af 100644 --- a/bun.lock +++ b/bun.lock @@ -371,7 +371,6 @@ "electron": "44.9.1", "electron-builder": "26.15.7", "electron-vite": "6.0.0-beta.1", - "htmlparser2": "8.0.2", "magic-string": "0.30.21", "solid-js": "catalog:", "typescript": "catalog:", diff --git a/packages/app/src/components-lab/lab.tsx b/packages/app/src/components-lab/lab.tsx index 3c2a6297f89..54ea9dd142b 100644 --- a/packages/app/src/components-lab/lab.tsx +++ b/packages/app/src/components-lab/lab.tsx @@ -724,11 +724,7 @@ function Tile(props: { entry: LabEntry }) { } function isDialogEntry(entry: LabEntry) { - return ( - entry.title.startsWith("App/Dialogs/") || - entry.file.endsWith("/beta-dialog.stories.tsx") || - entry.file.endsWith("/overlays/dialog/dialog.stories.tsx") - ) + return entry.title.startsWith("App/Dialogs/") || entry.file.endsWith("/overlays/dialog/dialog.stories.tsx") } function tileSize(entry: LabEntry) { diff --git a/packages/desktop/README.md b/packages/desktop/README.md index 328bc8fcdba..b77e4ecc653 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -41,11 +41,6 @@ the `beta` update channel. Beta publishes only the desktop app; the CLI and SDK from `dev`. Install it from `https://opencode.ai/update/api/beta/desktop/opencode/`, for example `opencode-desktop-mac-arm64.dmg` or `opencode-desktop-win-x64.exe`. Installed builds update in place on every platform. -Each beta build compiles in its commit and the `feat`, `fix` and `perf` commits among its newest 200 -(`script/beta-notes.ts`, embedded by `electron.vite.config.ts`; run `bun script/beta-notes.ts` to preview them). After an -update, the beta app lists the ones after the commit of the build the user saw last in a "What's new in beta" dialog, -unless release notes are turned off in Settings. - ## Startup benchmark `perf/startup.bench.ts` times a launch with [strobe](https://github.com/anomalyco/strobe), from process spawn to what a diff --git a/packages/desktop/electron.vite.config.ts b/packages/desktop/electron.vite.config.ts index 1e9dcf6e090..22aef746259 100644 --- a/packages/desktop/electron.vite.config.ts +++ b/packages/desktop/electron.vite.config.ts @@ -1,6 +1,5 @@ import { defineConfig } from "electron-vite" import type { Plugin } from "vite" -import { BetaNotes } from "./script/beta-notes" import { pickerPlugin } from "./script/picker" import { nodePtyPackage } from "./script/utils" @@ -34,19 +33,10 @@ const initialChunk: Plugin = { }, } -export default defineConfig(async ({ command }) => { +export default defineConfig(({ command }) => { const channel = resolveChannel() const nodePtyPkg = nodePtyPackage() - // Beta builds carry their commit and recent history for the updater's "What's new in beta"; the dev server carries - // them without asking GitHub, so the page can be tried; other channels none. - const betaNotes = - channel === "beta" - ? await BetaNotes.enrich(BetaNotes.embed(), { token: process.env.GITHUB_TOKEN }) - : command === "serve" - ? BetaNotes.embed() - : undefined - return { main: { resolve: { @@ -118,8 +108,6 @@ export default defineConfig(async ({ command }) => { define: { "import.meta.env.OPENCODE_VERSION": JSON.stringify(process.env.OPENCODE_VERSION), "import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel), - "import.meta.env.OPENCODE_COMMIT": betaNotes ? JSON.stringify(betaNotes.sha) : "undefined", - "import.meta.env.OPENCODE_BETA_NOTES": betaNotes ? JSON.stringify(JSON.stringify(betaNotes)) : "undefined", "import.meta.env.OPENCODE_TEST_ONBOARDING": JSON.stringify( command === "serve" && process.env.OPENCODE_TEST_ONBOARDING === "1", ), diff --git a/packages/desktop/package.json b/packages/desktop/package.json index bafdc134cd9..080ff96607b 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -50,7 +50,6 @@ "electron": "44.9.1", "electron-builder": "26.15.7", "electron-vite": "6.0.0-beta.1", - "htmlparser2": "8.0.2", "magic-string": "0.30.21", "solid-js": "catalog:", "typescript": "catalog:", diff --git a/packages/desktop/script/beta-notes.test.ts b/packages/desktop/script/beta-notes.test.ts deleted file mode 100644 index cec4374a841..00000000000 --- a/packages/desktop/script/beta-notes.test.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { afterAll, expect, test } from "bun:test" -import { $ } from "bun" -import { mkdtemp, rm } from "node:fs/promises" -import { tmpdir } from "node:os" -import path from "node:path" -import { BetaNotes } from "./beta-notes" - -test.each([ - [ - "feat(app): merge projects across servers in the location dialog", - { type: "feat", scope: "app", summary: "merge projects across servers in the location dialog" }, - ], - [ - "fix(desktop): disable animated wheel scrolling on linux (#57120)", - { type: "fix", scope: "desktop", summary: "disable animated wheel scrolling on linux", pr: 57120 }, - ], - ["perf: cache model list", { type: "perf", summary: "cache model list" }], - [ - "Feat(core)!: drop legacy sessions", - { type: "feat", scope: "core", summary: "drop legacy sessions", breaking: true }, - ], - [ - "fix(app): keep (#1) in the middle of a summary", - { type: "fix", scope: "app", summary: "keep (#1) in the middle of a summary" }, - ], - ["chore(sdk): regenerate types", undefined], - ["refactor(app): split dialog", undefined], - ["docs: update guide", undefined], - ["test(app): cover dialog", undefined], - ["ci: cache bun", undefined], - ["Merge branch 'dev' into beta", undefined], - ["fix the thing without a type", undefined], - ["fix(app): (#12)", undefined], -])("parses %p", (subject, expected) => { - expect(BetaNotes.parseSubject(subject)).toEqual(expected) -}) - -const repos: string[] = [] - -afterAll(() => Promise.all(repos.map((dir) => rm(dir, { recursive: true, force: true })))) - -async function repo(subjects: string[]) { - const dir = await mkdtemp(path.join(tmpdir(), "beta-notes-")) - repos.push(dir) - - await $`git init -q -b beta && git config user.email a@b.c && git config user.name Ada`.cwd(dir).quiet() - - // One fast-import writes every commit, where a commit per process is slow on Windows. - const stream = subjects - .map((subject, i) => - [ - "commit refs/heads/beta", - `committer Ada ${1_700_000_000 + i} +0000`, - `data ${Buffer.byteLength(subject)}`, - subject, - "", - ].join("\n"), - ) - .join("") - - await $`git fast-import --quiet < ${new Response(stream)}`.cwd(dir).quiet() - await $`git reset -q --hard beta`.cwd(dir).quiet() - - return { dir, shas: (await $`git rev-list --reverse HEAD`.cwd(dir).text()).trim().split("\n") } -} - -test("embeds the build's commit, its history and the notes of its non-merge feat and fix commits", async () => { - const git = await repo([ - "feat(app): old feature", - "fix(app): first fix (#10)", - "chore: bump", - "feat(desktop): new dialog", - ]) - - await $`git checkout -q -b side HEAD~1 && git commit -q --allow-empty -m ${"fix(app): side fix"} && git checkout -q beta && git merge -q --no-ff side -m ${"fix(app): merge side"}` - .cwd(git.dir) - .quiet() - - const head = (await $`git rev-parse HEAD`.cwd(git.dir).text()).trim() - const side = (await $`git rev-parse side`.cwd(git.dir).text()).trim() - const short = (sha: string) => sha.slice(0, 12) - const embedded = BetaNotes.embed({ head: "HEAD", cwd: git.dir }) - - expect(embedded.sha).toBe(head) - expect(embedded.commits).toHaveLength(6) - expect(embedded.commits[0]).toBe(short(head)) - const author = { name: "Ada" } - - expect(embedded.notes).toEqual([ - { type: "fix", scope: "app", summary: "side fix", sha: short(side), author }, - { type: "feat", scope: "desktop", summary: "new dialog", sha: short(git.shas[3]!), author }, - { type: "fix", scope: "app", summary: "first fix", pr: 10, sha: short(git.shas[1]!), author }, - { type: "feat", scope: "app", summary: "old feature", sha: short(git.shas[0]!), author }, - ]) -}) - -test("embeds at most the newest 200 commits", async () => { - const git = await repo(Array.from({ length: 205 }, (_, i) => `fix(app): fix ${i}`)) - - const embedded = BetaNotes.embed({ head: "HEAD", cwd: git.dir }) - - expect(embedded.commits).toHaveLength(200) - expect(embedded.notes[0]?.summary).toBe("fix 204") - expect(embedded.notes.at(-1)?.summary).toBe("fix 5") -}) - -const TEMPLATE = `### Issue for this PR - -Closes #1 - -### Type of change - -- [x] Bug fix - -### What does this PR do? - -Keep the composer's last line visible. Shift+Enter now scrolls far enough to clear the [fade](https://example.com). -A third sentence is left out. - -### How did you verify your code works? - -Tests.` - -test.each([ - [ - "the template's What does this PR do? section", - TEMPLATE, - "Keep the composer's last line visible. Shift+Enter now scrolls far enough to clear the fade.", - ], - [ - "a Summary list's first item", - "## Summary\n- Restore the **standard** border (`composer.tsx`).\n- Keep the row.", - "Restore the standard border.", - ], - [ - "the text before the first heading", - "Home showed a loading state.\n\n### Changes\n\n- Cache the list.", - "Home showed a loading state.", - ], - [ - "a Changes section when nothing comes first", - "### Why\n\nIt was slow.\n\n### Changes\n\nThe list stays cached.", - "The list stays cached.", - ], - [ - "nothing that introduces a code block", - "### What does this PR do?\n\nThe query was:\n\n```sql\nSELECT 1\n```", - undefined, - ], - ["nothing that repeats the subject", "Fix the thing.", undefined], - [ - "past the template's filler lines", - "**Issue for this PR:** N/A\nCloses #\nType of change: Bug fix\n- [x] Bug fix\n\n\nThe dialog opens at the top.", - "The dialog opens at the top.", - ], - [ - "the whole sentences that fit under the cap", - `Short first sentence. ${"Long ".repeat(50)}second.`, - "Short first sentence.", - ], - [ - "a long sentence cut at a word, with an ellipsis", - `Unifies the card surface, ${"and more words ".repeat(20)}end.`, - `Unifies the card surface, ${"and more words ".repeat(14).trim()}…`, - ], - ["nothing from an empty body", "", undefined], -])("describes %s", (_, body, expected) => { - expect(BetaNotes.describe(body, "fix the thing")).toBe(expected) -}) - -const notes = (): ReturnType => ({ - sha: "a".repeat(40), - commits: ["aaaaaaaaaaaa", "bbbbbbbbbbbb"], - notes: [ - { type: "fix", summary: "keep the last line visible", pr: 10, sha: "aaaaaaaaaaaa", author: { name: "Ada" } }, - { type: "feat", summary: "local change", sha: "bbbbbbbbbbbb", author: { name: "Ada" } }, - ], -}) - -test("adds each pull request's author and description from one GitHub query", async () => { - const requests: RequestInit[] = [] - - const enriched = await BetaNotes.enrich(notes(), { - token: "token", - fetch: async (_, init) => { - requests.push(init) - - return Response.json({ - data: { - repository: { - pr10: { - number: 10, - body: TEMPLATE, - author: { - login: "ada", - name: "Ada Lovelace", - avatarUrl: "https://avatars.githubusercontent.com/u/1?s=48", - }, - }, - }, - }, - }) - }, - }) - - expect(requests).toHaveLength(1) - expect(enriched.notes).toEqual([ - { - ...notes().notes[0]!, - author: { name: "Ada Lovelace", login: "ada", avatar: "https://avatars.githubusercontent.com/u/1?s=48" }, - description: "Keep the composer's last line visible. Shift+Enter now scrolls far enough to clear the fade.", - }, - notes().notes[1], - ]) -}) - -test("keeps only GitHub's public avatars, and the login when there is no name", async () => { - const enriched = await BetaNotes.enrich(notes(), { - token: "token", - fetch: async () => - Response.json({ - data: { - repository: { - pr10: { number: 10, body: "", author: { login: "bot", avatarUrl: "http://example.com/a.png" } }, - }, - }, - }), - }) - - expect(enriched.notes[0]?.author).toEqual({ name: "bot", login: "bot" }) -}) - -test.each([ - ["no token", undefined, async () => Response.json({})], - ["a failed request", "token", async () => new Response("rate limited", { status: 403 })], - ["a network error", "token", async () => Promise.reject(new Error("offline"))], - ["an unexpected response", "token", async () => Response.json({ errors: [{ message: "bad" }] })], -])("keeps the commit authors and subjects with %s", async (_, token, request) => { - expect(await BetaNotes.enrich(notes(), { token, fetch: request })).toEqual(notes()) -}) - -const SVG = `` - -const withVisual = (svg: string) => - `### What does this PR do?\n\nIt grows.\n\n### Visual\n\n\`\`\`svg\n${svg}\n\`\`\`\n\n### Checklist` - -test("rebuilds a visual with its namespace, and a still without its animation", () => { - expect(BetaNotes.visual(withVisual(SVG))).toEqual({ - animated: ``, - still: ``, - }) -}) - -test("drops scripts, foreign objects, images, handlers, outside links and SMIL that animates a link", () => { - const rebuilt = BetaNotes.visual( - withVisual( - `
`, - ), - ) - - expect(rebuilt?.animated).toBe(``) -}) - -test("drops a still's keyframes and animations, even important or inline ones", () => { - const rebuilt = BetaNotes.visual( - withVisual( - ``, - ), - ) - - expect(rebuilt?.still).toBe( - ``, - ) - expect(rebuilt?.animated).toContain("@keyframes spin") -}) - -test.each([ - ["no Visual section", "### What does this PR do?\n\n```svg\n\n```"], - ["a root that is not an svg", withVisual("
")], - ["more than one svg root", withVisual("")], - ["a doctype", withVisual(']>')], - ["an @import", withVisual('')], - ["a CSS-escaped @import", withVisual('')], - ["a CSS-escaped url()", withVisual('')], - ["an outside url() in an attribute", withVisual('')], - [ - "an outside url() in an animation", - withVisual(''), - ], - ["an entity-encoded url()", withVisual('')], - ["more than 8 KB", withVisual(`${''.repeat(600)}`)], -])("refuses a visual with %s", (_, body) => { - expect(BetaNotes.visual(body)).toBeUndefined() -}) - -test("keeps the visual's code out of the description", () => { - expect(BetaNotes.describe(`\`\`\`svg\n${SVG}\n\`\`\`\n\nIt grows.`, "grow")).toBe("It grows.") -}) diff --git a/packages/desktop/script/beta-notes.ts b/packages/desktop/script/beta-notes.ts deleted file mode 100644 index 50ee9200569..00000000000 --- a/packages/desktop/script/beta-notes.ts +++ /dev/null @@ -1,313 +0,0 @@ -// The user-facing changes compiled into a beta build: the build's commit, the newest commits of its history, and the -// feat, fix and perf commits among them, with who made each and what its pull request says it does when GitHub can be -// asked. electron.vite.config.ts embeds them in the renderer as import.meta.env.OPENCODE_BETA_NOTES; the updater's -// "What's new in beta" dialog lists the ones after the commit of the build the user saw last. -// Usage: GITHUB_TOKEN=... bun script/beta-notes.ts [head] -export * as BetaNotes from "./beta-notes" - -import { execFileSync } from "node:child_process" -import { Option, Schema } from "effect" -import { BetaVisual } from "./beta-visual" - -export type BetaNote = { - type: "feat" | "fix" | "perf" - scope?: string - summary: string - breaking?: true - pr?: number - sha: string - /** The commit's author, or the pull request's once GitHub has been asked. */ - author?: { name: string; login?: string; avatar?: string } - /** A sentence or two from the pull request's description of what it does. */ - description?: string - /** The pull request's "### Visual": a small looping SVG of the change, rebuilt, and its still for reduced motion. */ - visual?: { animated: string; still: string } -} - -/** How many commits of history a build carries. A previous build further back shows the newest notes instead. */ -const LIMIT = 200 - -/** Commits are identified by this many hex digits, enough to be unique in this repository. */ -const SHORT = 12 - -const KEPT = new Set(["feat", "fix", "perf"]) - -const FIELD = "\x1f" - -const RECORD = "\x1e" - -const REPOSITORY = { owner: "anomalyco", name: "opencode" } - -/** Pull requests per GitHub query; the newest 200 commits take at most two. */ -const BATCH = 100 - -/** A description longer than this reads as a wall of text in the dialog, so it is cut to fit. */ -const DESCRIPTION = 240 - -/** - * Reads the commit `head` resolves to (GITHUB_SHA in CI) and its newest commits, newest first: every commit's short - * sha, merges included so a previous build at a merge is found, and the notes of the non-merge feat, fix and perf ones. - */ -export function embed(input: { head?: string; cwd?: string } = {}) { - // electron-vite loads its config, which calls this, in Node, so this cannot use Bun's shell. - const git = (...args: string[]) => execFileSync("git", args, { cwd: input.cwd, encoding: "utf8" }) - - const sha = git("rev-parse", input.head ?? process.env.GITHUB_SHA ?? "HEAD").trim() - - const log = git( - "log", - "--topo-order", - `--max-count=${LIMIT}`, - `--format=%H${FIELD}%P${FIELD}%an${FIELD}%s${RECORD}`, - sha, - ) - - const records = log - .split(RECORD) - .map((record) => record.trim().split(FIELD)) - .filter((fields) => fields.length === 4 && fields[0]) - - return { - sha, - commits: records.map(([commit]) => commit!.slice(0, SHORT)), - notes: records.flatMap(([commit, parents, author, subject]): BetaNote[] => { - // A merge commit repeats the commits it merges. - if (parents!.trim().split(/\s+/).length > 1) return [] - - const note = parseSubject(subject!) - - if (!note) return [] - - return [{ ...note, sha: commit!.slice(0, SHORT), author: author!.trim() ? { name: author!.trim() } : undefined }] - }), - } -} - -/** - * Adds each note's pull request author (name, login and public avatar) and description, read from GitHub with one - * GraphQL query per 100 pull requests. The token stays here: only public data reaches the build. Without a token, or - * when GitHub fails or is slow, the notes keep their commit author and subject: a build never fails for this. - */ -export async function enrich( - embedded: ReturnType, - input: { token: string | undefined; fetch?: Request }, -): Promise> { - const numbers = [...new Set(embedded.notes.flatMap((note) => note.pr ?? []))] - const token = input.token - - if (!token || numbers.length === 0) return embedded - - const batches = Array.from({ length: Math.ceil(numbers.length / BATCH) }, (_, i) => - numbers.slice(i * BATCH, (i + 1) * BATCH), - ) - - const found = new Map( - (await Promise.all(batches.map((batch) => pullRequests(batch, token, input.fetch ?? fetch)))).flat(), - ) - - return { - ...embedded, - notes: embedded.notes.map((note) => { - const pr = note.pr === undefined ? undefined : found.get(note.pr) - - if (!pr) return note - - return { - ...note, - author: pr.author ? author(pr.author) : note.author, - description: describe(pr.body, note.summary), - visual: visual(pr.body), - } - }), - } -} - -/** - * The first sentence or two of what a pull request says it does, as plain text: from the PR template's "What does this - * PR do?" or a "Summary" section, else the text before the first heading, else a "Changes" or "Solution" section. The - * template's own lines (issue links, "N/A", the type-of-change checklist) never count. Text over the cap ends at its - * last whole sentence that fits, or is cut at a word with an ellipsis. None when it only repeats the subject. - */ -export function describe(body: string, summary: string) { - const heading = (line: string) => /^#{1,6}\s/.test(line) || /^\*\*[^*]+\*\*:?$/.test(line) - - // Template headings stay, as they end the section before them. - const lines = body - .replace(/|```[\s\S]*?```/g, "") - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => heading(line) || !BOILERPLATE.some((pattern) => pattern.test(line))) - - const until = (rest: string[]) => { - const end = rest.findIndex(heading) - - return end === -1 ? rest : rest.slice(0, end) - } - - const section = (title: RegExp) => { - const start = lines.findIndex((line) => heading(line) && title.test(line)) - - return start === -1 ? [] : until(lines.slice(start + 1)) - } - - const words = (value: string) => - value - .toLowerCase() - .replace(/[^a-z0-9]+/g, " ") - .trim() - - return [section(/what does this pr do|summary/i), until(lines), section(/^\W*(what )?chang|solution/i)] - .map((candidate) => { - const paragraph = candidate - .join("\n") - .split(/\n\s*\n/) - // A list stands for its first item. - .map((block) => plain(block.split("\n").find((line) => /^([-*+]|\d+\.)\s/.test(line)) ?? block)) - .find( - (text) => text && !/^(\[.\]|(closes|fixes|resolves|stacked on|depends on|n\/a|please provide)\b)/i.test(text), - ) - - // A sentence that introduces a code block or a list reads as cut off. - const [first, second] = (paragraph ?? "") - .split(/(?<=[.!?])\s+(?=[A-Z0-9"'`(])/) - .filter((sentence) => sentence && !sentence.endsWith(":")) - - const text = first && second && `${first} ${second}`.length <= DESCRIPTION ? `${first} ${second}` : first - - // Fragments of a list, or text that starts with code, don't read as a sentence on their own. - return text && /^[A-Z0-9"“]/.test(text) && words(text) !== words(summary) ? cap(text) : undefined - }) - .find((text) => text !== undefined) -} - -/** - * The ```svg block under a pull request's "### Visual" heading, rebuilt from an allowlist (beta-visual.ts) as an - * animated and a still copy. The dialog only ever shows it as an image, where scripts never run. - */ -export function visual(body: string) { - const lines = body.split(/\r?\n/) - const start = lines.findIndex((line) => /^#{1,6}\s+visual\b/i.test(line.trim())) - - if (start === -1) return undefined - - const rest = lines.slice(start + 1) - const end = rest.findIndex((line) => /^#{1,6}\s/.test(line.trim())) - const block = (end === -1 ? rest : rest.slice(0, end)).join("\n").match(/```svg[^\n]*\n([\s\S]*?)\n\s*```/)?.[1] - - return block ? BetaVisual.sanitize(block) : undefined -} - -/** Lines of the PR template that say nothing about the change, whether a heading, a label or a filler answer. */ -const BOILERPLATE = [ - /^\W*(issue for this pr|type of change|how did you verify|screenshots|checklist)\b/i, - /^[-*+]\s*\[[ x]\]/i, - /^\W*(n\/a|none|tbd)\W*$/i, - /^\W*((closes|fixes|resolves)\s*)?([\w.-]+\/[\w.-]+)?#\d*\W*$/i, -] - -/** A sentence over the cap, cut at its last word that fits, with an ellipsis. */ -function cap(text: string) { - if (text.length <= DESCRIPTION) return text - - const cut = text.slice(0, DESCRIPTION - 1).replace(/\s+\S*$/, "") - - return `${cut.replace(/[\s,;:—–-]+$/, "")}…` -} - -/** Markdown as the plain text it reads as. */ -function plain(markdown: string) { - return ( - markdown - .replace(/!\[[^\]]*\]\([^)]*\)/g, "") - // Parenthesized file paths and identifiers are for reviewers. - .replace(/\s*\([^()]*`[^()]*\)/g, "") - .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") - .replace(/<[^>]+>/g, "") - .replace(/^\s*(?:[-*+]|\d+\.|>)\s+/gm, "") - .replace(/(\*\*|__|`)/g, "") - .replace(/\s+/g, " ") - .trim() - ) -} - -const Author = Schema.Struct({ - login: Schema.String, - avatarUrl: Schema.optional(Schema.String), - name: Schema.optional(Schema.NullOr(Schema.String)), -}) - -const PullRequest = Schema.Struct({ number: Schema.Number, body: Schema.String, author: Schema.NullOr(Author) }) - -const Response = Schema.Struct({ - data: Schema.Struct({ repository: Schema.Record(Schema.String, Schema.NullOr(PullRequest)) }), -}) - -const decodeResponse = Schema.decodeUnknownOption(Response) - -type Request = (url: string, init: RequestInit) => Promise - -/** The pull requests GitHub returns for `numbers`; none when the request fails. Missing numbers are left out. */ -async function pullRequests(numbers: number[], token: string, request: Request) { - const fields = numbers - .map( - (n) => - `pr${n}: pullRequest(number: ${n}) { number body author { login avatarUrl(size: 48) ... on User { name } } }`, - ) - .join("\n") - - const query = `query { repository(owner: "${REPOSITORY.owner}", name: "${REPOSITORY.name}") { ${fields} } }` - - const response = await request("https://api.github.com/graphql", { - method: "POST", - headers: { authorization: `bearer ${token}`, "content-type": "application/json", "user-agent": "opencode-desktop" }, - body: JSON.stringify({ query }), - signal: AbortSignal.timeout(10_000), - }) - .then((result) => (result.ok ? result.json() : undefined)) - .catch(() => undefined) - - return Option.match(decodeResponse(response), { - onNone: () => [], - onSome: (decoded) => Object.values(decoded.data.repository).flatMap((pr) => (pr ? [[pr.number, pr] as const] : [])), - }) -} - -/** A pull request author as the dialog shows them; only GitHub's public avatar host is kept. */ -function author(input: typeof Author.Type): NonNullable { - const avatar = input.avatarUrl?.startsWith("https://avatars.githubusercontent.com/") ? input.avatarUrl : undefined - - return { name: input.name?.trim() || input.login, login: input.login, avatar } -} - -/** Parses a conventional commit header `type(scope)!: summary (#123)`; anything else is not a note. */ -export function parseSubject(subject: string) { - const match = subject.trim().match(/^(\w+)(?:\(([^)]*)\))?(!)?:\s*(.+)$/) - const type = match?.[1]?.toLowerCase() - - if (!match || !type || !isKept(type)) return undefined - - const pr = match[4]!.match(/\s*\(#(\d+)\)\s*$/) - const summary = (pr ? match[4]!.slice(0, pr.index) : match[4]!).trim() - - if (!summary) return undefined - - const note: Omit = { type, summary } - const scope = match[2]?.trim() - - if (scope) note.scope = scope - - if (match[3]) note.breaking = true - - if (pr) note.pr = Number(pr[1]) - - return note -} - -function isKept(type: string): type is BetaNote["type"] { - return KEPT.has(type) -} - -if (import.meta.main) - console.log( - JSON.stringify(await enrich(embed({ head: process.argv[2] }), { token: process.env.GITHUB_TOKEN }), null, 2), - ) diff --git a/packages/desktop/script/beta-visual.ts b/packages/desktop/script/beta-visual.ts deleted file mode 100644 index 15dbfab2051..00000000000 --- a/packages/desktop/script/beta-visual.ts +++ /dev/null @@ -1,278 +0,0 @@ -// A pull request's "### Visual" SVG, rebuilt from an allowlist for the What's new in beta dialog, which shows it only -// as an . The input is parsed as XML and serialized again from scratch: exactly one root, known shape, -// paint and animation elements and attributes only, references to the document itself only, and no CSS escapes. -export * as BetaVisual from "./beta-visual" - -import { DomUtils, parseDocument } from "htmlparser2" - -/** The most bytes a visual may take once rebuilt. */ -const LIMIT = 8 * 1024 - -const ELEMENTS = new Set([ - "svg", - "g", - "defs", - "symbol", - "use", - "rect", - "circle", - "ellipse", - "line", - "polyline", - "polygon", - "path", - "text", - "tspan", - "title", - "desc", - "clipPath", - "mask", - "linearGradient", - "radialGradient", - "stop", - "style", - "animate", - "animateTransform", - "animateMotion", - "set", -]) - -const SMIL = new Set(["animate", "animateTransform", "animateMotion", "set"]) - -const ATTRIBUTES = new Set([ - "id", - "class", - "style", - "viewBox", - "preserveAspectRatio", - "width", - "height", - "x", - "y", - "x1", - "y1", - "x2", - "y2", - "cx", - "cy", - "r", - "rx", - "ry", - "fx", - "fy", - "d", - "points", - "pathLength", - "transform", - "transform-origin", - "href", - "color", - "fill", - "fill-opacity", - "fill-rule", - "stroke", - "stroke-width", - "stroke-opacity", - "stroke-linecap", - "stroke-linejoin", - "stroke-dasharray", - "stroke-dashoffset", - "stroke-miterlimit", - "opacity", - "visibility", - "display", - "vector-effect", - "paint-order", - "clip-path", - "clip-rule", - "clipPathUnits", - "mask", - "maskUnits", - "maskContentUnits", - "offset", - "stop-color", - "stop-opacity", - "gradientUnits", - "gradientTransform", - "spreadMethod", - "font-size", - "font-weight", - "font-family", - "letter-spacing", - "text-anchor", - "dominant-baseline", - "attributeName", - "attributeType", - "values", - "from", - "to", - "by", - "dur", - "begin", - "end", - "repeatCount", - "repeatDur", - "keyTimes", - "keySplines", - "keyPoints", - "calcMode", - "additive", - "accumulate", - "restart", - "type", - "rotate", - "path", -]) - -/** Where text may appear; elsewhere it is formatting whitespace. */ -const TEXT = new Set(["text", "tspan", "title", "desc", "style"]) - -type Node = ReturnType["children"][number] - -/** - * The visual rebuilt twice: `animated` as drawn, and `still` with its SMIL elements, keyframes and animation and - * transition declarations removed, for reduced motion. None when it is not one root, uses anything outside the - * allowlist that can reach outside the document (a CSS escape, `@import`, a `url()` or `href` that is not `#id`, a - * doctype), or is over 8 KB. - */ -export function sanitize(input: string) { - if (input.length > LIMIT * 4 || /]*\?>/, ""), { - xmlMode: true, - recognizeCDATA: true, - recognizeSelfClosing: true, - }) - - const roots = document.children.filter( - (node) => !DomUtils.isComment(node) && !(DomUtils.isText(node) && !node.data.trim()), - ) - - const root = roots[0] - - if (roots.length !== 1 || !root || !DomUtils.isTag(root) || root.name !== "svg") return undefined - - const animated = serialize(root, false) - const still = serialize(root, true) - - if (animated === undefined || still === undefined || new TextEncoder().encode(animated).length > LIMIT) - return undefined - - return { animated, still } -} - -/** One element and its allowed children as markup; undefined anywhere means the whole visual is refused. */ -function serialize(node: Node, still: boolean): string | undefined { - if (!DomUtils.isTag(node) || !ELEMENTS.has(node.name)) return "" - - const name = node.name - - if (SMIL.has(name) && (still || /href$/i.test(node.attribs.attributeName ?? ""))) return "" - - const attributes = Object.entries(node.attribs).flatMap(([key, value]): [string, string | undefined][] => { - const attribute = key === "xlink:href" ? "href" : key - - if (!ATTRIBUTES.has(attribute)) return [] - - if (attribute === "href" && !/^#[\w-]+$/.test(value)) return [] - - if (attribute === "style") return [[attribute, declarations(value, still)]] - - return [[attribute, safe(value) ? value : undefined]] - }) - - // A CSS escape or an outside reference in any value refuses the whole visual, rather than drawing part of it. - if (attributes.some(([, value]) => value === undefined)) return undefined - - const children = node.children.map((child) => { - if (DomUtils.isTag(child)) return serialize(child, still) - - if (DomUtils.isCDATA(child) || DomUtils.isText(child)) return text(name, DomUtils.textContent(child), still) - - return "" - }) - - if (children.includes(undefined)) return undefined - - const open = [ - name, - ...(name === "svg" ? [`xmlns="http://www.w3.org/2000/svg"`] : []), - ...attributes.map(([key, value]) => `${key}="${escape(value)}"`), - ].join(" ") - - const body = children.join("") - - return body ? `<${open}>${body}` : `<${open}/>` -} - -/** An element's text, kept only where text means something; a stylesheet must pass `stylesheet`. */ -function text(element: string, value: string, still: boolean) { - if (!TEXT.has(element)) return "" - - if (element !== "style") return escape(value) - - const css = stylesheet(value, still) - - return css === undefined ? undefined : escape(css) -} - -/** A stylesheet that only styles this document: rules, `@keyframes` and `@media`, no escapes or outside urls. */ -function stylesheet(value: string, still: boolean) { - const css = value.replace(/\/\*[\s\S]*?\*\//g, "") - - if (!safe(css) || /@(?!(-webkit-)?keyframes\b|media\b)/i.test(css)) return undefined - - if (!still) return css.trim() - - return withoutKeyframes(css) - .replace(/\{([^{}]*)\}/g, (_, body: string) => `{${declarations(body, true)}}`) - .trim() -} - -/** A declaration list, without its animations and transitions for the still. */ -function declarations(value: string, still: boolean) { - const css = value.replace(/\/\*[\s\S]*?\*\//g, "") - - if (!safe(css)) return undefined - - if (!still) return css.trim() - - return css - .split(";") - .filter((declaration) => !/^\s*(-webkit-)?(animation|transition)(-[\w-]+)?\s*:/i.test(declaration)) - .join(";") - .trim() -} - -/** The stylesheet without its `@keyframes` blocks, braces balanced. */ -function withoutKeyframes(css: string) { - const start = css.search(/@(-webkit-)?keyframes\b/i) - - if (start === -1) return css - - const open = css.indexOf("{", start) - - if (open === -1) return css.slice(0, start) - - const end = Array.from(css.slice(open)).reduce( - (state, char, index) => { - if (state.end !== -1) return state - - const depth = state.depth + ({ "{": 1, "}": -1 }[char] ?? 0) - - return { depth, end: depth === 0 ? open + index + 1 : -1 } - }, - { depth: 0, end: -1 }, - ).end - - return css.slice(0, start) + (end === -1 ? "" : withoutKeyframes(css.slice(end))) -} - -/** No CSS escapes, no `@import`, and every `url()` points into the document. */ -function safe(value: string) { - return !value.includes("\\") && !/@import/i.test(value) && !/url\(\s*(?!['"]?#)/i.test(value) -} - -function escape(value: string) { - return value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """) -} diff --git a/packages/gui-plugins/src/env.d.ts b/packages/gui-plugins/src/env.d.ts deleted file mode 100644 index 0e58dcc1e70..00000000000 --- a/packages/gui-plugins/src/env.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -interface ImportMetaEnv { - /** The commit a beta desktop build was built from; undefined in every other build. */ - readonly OPENCODE_COMMIT?: string - /** A beta desktop build's commit, recent history and notes as JSON (packages/desktop/script/beta-notes.ts). */ - readonly OPENCODE_BETA_NOTES?: string -} diff --git a/packages/gui-plugins/src/updater/beta-dialog.stories.tsx b/packages/gui-plugins/src/updater/beta-dialog.stories.tsx deleted file mode 100644 index ed238ff977d..00000000000 --- a/packages/gui-plugins/src/updater/beta-dialog.stories.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import { Button } from "@opencode/ui/button" -import { useDialog } from "@opencode/ui/context/dialog" -import { Option } from "effect" -import { createSignal, onMount, Show } from "solid-js" -import { SettingsPanel } from "../../../stories/mocks/gui-plugins/settings-page" -import { DialogBetaNotes } from "./beta-dialog" -import { BetaNotesView } from "./beta-page" -import { sliceBetaNotes, type EmbeddedNotes } from "./beta-notes" - -type Author = { name: string; login?: string; avatar?: string } - -// Fictional people with drawn avatars, so the stories show no real names or photos. -const avatar = (hue: number) => - `data:image/svg+xml,${encodeURIComponent( - ``, - )}` - -const people = { - mira: { name: "Mira Okafor", login: "mira", avatar: avatar(18) }, - theo: { name: "Theo Lindqvist", login: "theo", avatar: avatar(205) }, - juno: { name: "Juno Park", login: "juno", avatar: avatar(140) }, - ravi: { name: "ravi-c", login: "ravi-c" }, - wren: { name: "Wren Abernathy", login: "wren", avatar: avatar(265) }, -} satisfies Record - -// Example visuals as a build embeds them from a pull request's "### Visual": animated, and the still for reduced motion. -const visuals = { - dock: { - animated: - '', - still: - '', - }, - spinners: { - animated: - '', - still: - '', - }, - palette: { - animated: - '', - still: - '', - }, - sidebar: { - animated: - '', - still: - '', - }, -} - -const commits: [ - type: "feat" | "fix" | "perf", - summary: string, - pr: number | undefined, - author: Author, - description?: string, - visual?: { animated: string; still: string }, -][] = [ - [ - "feat", - "grow questions and permissions out of the composer", - 57103, - people.mira, - "Questions and permission requests grow out of the top of the composer, and fold back into it once answered.", - visuals.dock, - ], - [ - "fix", - "keep activity spinners in step", - 57101, - people.theo, - "Every busy spinner on screen turns in step, wherever it appeared.", - visuals.spinners, - ], - [ - "feat", - "find settings from the command palette", - 57094, - people.juno, - "Typing in the command palette also finds settings, in their own group.", - visuals.palette, - ], - ["fix", "open activity panel above the capsule", 57088, people.ravi], - [ - "feat", - "glide the sidebar open and closed", - 57090, - people.wren, - "The sidebar slides in and out, and the content beside it moves with its edge.", - visuals.sidebar, - ], - ["fix", "disable animated wheel scrolling on linux", 57080, people.wren], - ["perf", "cache the model list between sessions", 57050, people.juno], - ["feat", "merge projects across servers in the location dialog", 55838, people.theo], - ["fix", "fit connect dialog steps to their content under a fixed top edge", 57006, people.wren], - ["feat", "add an experimental session inbox to the sidebar", 55647, people.ravi], - ["fix", "debounce compatibility skill reloads", 56260, people.theo], - ["fix", "restore blue user bubbles with grey pending steers", 57001, people.mira], - ["feat", "open images from their place in the message", 56896, people.juno], - ["fix", "install each Windows channel into its own directory", 56904, people.wren], - ["feat", "show MCP errors from a failed status link", undefined, people.ravi], - ["fix", "open dialogs with their input focused", undefined, people.theo], - ["fix", "default agent notifications to off", 56990, people.juno], - ["feat", "add Focus and Pro interface picker to preferences", 56898, people.mira], -] - -const sha = (n: number) => n.toString(16).padStart(4, "0").padEnd(40, "a") - -// A build's embedded history: one commit per entry, newest first, with a chore between the notes. -const embedded = (picked: typeof commits): EmbeddedNotes => ({ - sha: sha(1000), - commits: [sha(1000), ...picked.flatMap((_, i) => [sha(i * 2 + 1), sha(i * 2 + 2)]), sha(999)].map((value) => - value.slice(0, 12), - ), - notes: picked.map(([type, summary, pr, author, description, visual], i) => ({ - type, - summary, - pr, - sha: sha(i * 2 + 1).slice(0, 12), - author, - description, - visual, - })), -}) - -// What a build without GitHub access embeds: commit authors without avatars, and no visuals. -const offline = (picked: typeof commits): typeof commits => - picked.map(([type, summary, pr, author]) => [type, summary, pr, { name: author.name }]) - -// View all opens the What's new page in place of the story, as the app opens it in Settings. -function Fixture(props: { picked: typeof commits; previous?: string; previousCommit?: string }) { - const dialog = useDialog() - const [page, setPage] = createSignal(false) - const notes = Option.getOrThrow(sliceBetaNotes(embedded(props.picked), props.previousCommit ?? sha(999))) - - const open = () => - dialog.show(() => ( - dialog.close() }} - open={(href) => window.open(href, "_blank")} - viewAll={() => { - dialog.close() - setPage(true) - }} - /> - )) - - onMount(open) - - return ( - Open What's new in beta}> - - window.open(href, "_blank")} /> - - - ) -} - -export default { - title: "Plugins/Updater/What's new in beta", - id: "plugins-updater-beta-notes", - parameters: { - docs: { - description: { - component: - "The dialog a beta desktop build shows after an update: the features and fixes since the build the user last saw.", - }, - }, - }, -} - -/** A typical update: a few features and fixes. */ -export const Typical = { render: () => } - -/** More than ten changes: View all opens the What's new page. */ -export const Many = { - render: () => , -} - -/** Built without GitHub: commit authors as initials, and kind icons where the visuals would be. */ -export const Offline = { render: () => } - -/** Only fixes, so the New section is not shown. */ -export const FixesOnly = { - render: () => type === "fix").slice(0, 4)} />, -} - -/** The previous build is older than the embedded history: the newest changes stand in, and View all shows the rest. */ -export const PreviousBuildUnknown = { - render: () => , -} - -/** Settings > What's new in beta: every change the build carries, from Settings or the dialog's View all. */ -export const Page = { - render: () => ( - - window.open(href, "_blank")} /> - - ), -} diff --git a/packages/gui-plugins/src/updater/beta-dialog.tsx b/packages/gui-plugins/src/updater/beta-dialog.tsx deleted file mode 100644 index 0120bad9c7a..00000000000 --- a/packages/gui-plugins/src/updater/beta-dialog.tsx +++ /dev/null @@ -1,237 +0,0 @@ -import { createSignal, For, onCleanup, onMount, Show } from "solid-js" -import { createResizeObserver } from "@solid-primitives/resize-observer" -import { Option } from "effect" -import { Button } from "@opencode/ui/button" -import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog" -import { prefersReducedMotion } from "@opencode/ui/motion" -import { usePlugin, type DialogHandle, type SetupContext } from "@opencode/plugin/gui" -import { - betaBuild, - decodeEmbeddedNotes, - sliceBetaNotes, - visualSource, - type BetaNote, - type BetaNotes, -} from "./beta-notes" -import type definition from "./index" - -/** - * The features and fixes this beta build carries since the commit of the build the user saw last; undefined when there - * are none. Everything comes from the build itself; only the links reach the network, when clicked. - */ -export function readBetaNotes(previousCommit: string | undefined) { - return Option.getOrUndefined( - Option.flatMap(decodeEmbeddedNotes(import.meta.env.OPENCODE_BETA_NOTES), (embedded) => - sliceBetaNotes(embedded, previousCommit), - ), - ) -} - -/** Opens What's new in beta above any open dialog. */ -export function openBetaNotes(ctx: SetupContext, notes: BetaNotes, previous: string) { - ctx.dialogs.open((dialog) => ( - ctx.system.openExternal(href)} - viewAll={() => { - dialog.close() - ctx.layout.settings("whats-new") - }} - /> - )) -} - -/** What's new in beta: the listed features and fixes, and the What's new page when some are not listed. */ -export function DialogBetaNotes(props: { - notes: BetaNotes - previous: string - dialog: DialogHandle - open: (href: string) => void - viewAll: () => void -}) { - const ctx = usePlugin() - const [fade, setFade] = createSignal({ top: 0, bottom: 0 }) - let list!: HTMLDivElement - - // The edges feather by as much as there is left to scroll, so they appear and leave without a jump. - const measure = () => - setFade({ - top: Math.min(FADE, list.scrollTop), - bottom: Math.min(FADE, list.scrollHeight - list.clientHeight - list.scrollTop), - }) - - onMount(() => { - measure() - createResizeObserver(list, measure) - }) - - return ( - - - - - -
-
-
-
- - - - - - - - - ) -} - -/** How far the list's edges feather once it scrolls. */ -const FADE = 24 - -// Every entry starts on the heading's edge; a visual sits small at the row's end, so rows keep one rhythm with or -// without one. -function Section(props: { title: string; notes: readonly BetaNote[]; open: (href: string) => void }) { - return ( - 0}> -
-

{props.title}

-
    - - {(note) => ( -
  • -
    -

    - {note.summary} -

    - -
    - {(visual) => } -
  • - )} -
    -
-
-
- ) -} - -/** What changed, drawn: the pull request's looping SVG at half size, only ever as an image, in the icon colour. */ -export function Visual(props: { visual: NonNullable; label?: string }) { - const [color, setColor] = createSignal() - let frame!: HTMLSpanElement - - // The theme lives on the root element's attributes; read the colour again when they change. - onMount(() => { - const read = () => setColor(getComputedStyle(frame).color) - const observer = new MutationObserver(read) - - read() - observer.observe(document.documentElement, { attributes: true }) - onCleanup(() => observer.disconnect()) - }) - - return ( - - - {(value) => ( - {props.label - )} - - - ) -} - -/** Who made it and its pull request, quietly; `class` sets the type, the dialog's own scale by default. */ -export function Meta(props: { note: BetaNote; open: (href: string) => void; class?: string }) { - return ( - -
- - {(author) => ( - - - {author().name} - - )} - - - {(pr) => ( - <> - - - - { - event.preventDefault() - props.open(pr().url) - }} - > - #{pr().number} - - - )} - -
-
- ) -} - -/** The author's initial, which the avatar fades in over once it loads; an avatar that fails leaves the initial. */ -function Avatar(props: { name: string; src?: string }) { - const [loaded, setLoaded] = createSignal(false) - - return ( - - ) -} diff --git a/packages/gui-plugins/src/updater/beta-notes.test.ts b/packages/gui-plugins/src/updater/beta-notes.test.ts deleted file mode 100644 index 7a16b3e00bf..00000000000 --- a/packages/gui-plugins/src/updater/beta-notes.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { expect, test } from "bun:test" -import { Option } from "effect" -import { - allBetaNotes, - betaBuild, - betaHistory, - decodeEmbeddedNotes, - sliceBetaNotes, - visualSource, - type EmbeddedNotes, -} from "./beta-notes" - -const sha = (n: number) => n.toString(16).padStart(4, "0").padEnd(40, "a") - -const short = (n: number) => sha(n).slice(0, 12) - -// A build at commit 30 whose history runs from 30 down to 1, with the note type `notes` gives each commit. -function embedded(input: { notes: (n: number) => "feat" | "fix" | "chore" }): EmbeddedNotes { - const commits = Array.from({ length: 30 }, (_, i) => 30 - i) - - return { - sha: sha(30), - commits: commits.map(short), - notes: commits.flatMap((n) => { - const type = input.notes(n) - - return type === "chore" ? [] : [{ type, summary: `change ${n}`, sha: short(n) }] - }), - } -} - -test("lists the notes after the previous commit, features first, humanized, with pull request links", () => { - const notes = sliceBetaNotes( - { - sha: sha(30), - commits: [short(30), short(29), short(28), short(27), short(26)], - notes: [ - { - type: "fix", - summary: "disable animated wheel scrolling on linux.", - pr: 57120, - sha: short(30), - author: { name: "Ada Lovelace", login: "ada", avatar: "https://avatars.githubusercontent.com/u/1?s=48" }, - description: "Wheel scrolling on Linux no longer glides.", - }, - { type: "feat", summary: "merge projects across servers", sha: short(29) }, - { type: "perf", summary: "cache the model list", sha: short(28) }, - { type: "feat", summary: "already seen", sha: short(26) }, - ], - }, - sha(27), - ) - - expect(Option.getOrThrow(notes)).toEqual({ - added: [{ sha: short(29), type: "feat", summary: "Merge projects across servers" }], - fixed: [ - { - sha: short(30), - type: "fix", - summary: "Disable animated wheel scrolling on linux", - description: "Wheel scrolling on Linux no longer glides.", - pr: { number: 57120, url: "https://github.com/anomalyco/opencode/pull/57120" }, - author: { name: "Ada Lovelace", avatar: "https://avatars.githubusercontent.com/u/1?s=48" }, - }, - { sha: short(28), type: "perf", summary: "Cache the model list" }, - ], - total: 3, - more: false, - }) -}) - -test.each([ - // previous commit, note types, [features, fixes] listed, total, more - ["found, all listed", sha(20), (n: number) => (n % 3 === 0 ? "feat" : "chore"), [4, 0], 4, false], - ["found, more than 10", sha(5), (n: number) => (n % 2 ? "fix" : "feat"), [5, 5], 25, true], - ["found, mostly features", sha(10), (n: number) => (n < 28 ? "feat" : "fix"), [7, 3], 20, true], - ["missing from the history", sha(99), (n: number) => (n % 2 ? "fix" : "feat"), [5, 5], 10, true], -] as const)("previous commit %s", (_, previous, notes, shown, total, more) => { - const sliced = Option.getOrThrow(sliceBetaNotes(embedded({ notes }), previous)) - - expect([sliced.added.length, sliced.fixed.length]).toEqual([...shown]) - expect(sliced.total).toBe(total) - expect(sliced.more).toBe(more) -}) - -test.each([ - ["no previous commit", undefined, () => "feat" as const], - ["the same build", sha(30), () => "feat" as const], - ["only chores since the previous commit", sha(25), (n: number) => (n < 25 ? "feat" : "chore")], -] as const)("shows nothing for %s", (_, previous, notes) => { - expect(Option.isNone(sliceBetaNotes(embedded({ notes }), previous))).toBe(true) -}) - -test("decodes the embedded notes, and none from a build without them", () => { - const value = embedded({ notes: () => "fix" }) - - expect(Option.getOrThrow(decodeEmbeddedNotes(JSON.stringify(value)))).toEqual(value) - expect(Option.isNone(decodeEmbeddedNotes(undefined))).toBe(true) -}) - -test.each([ - ["0.0.0-beta-21180", "21180"], - ["0.0.0-beta-21180.2", "21180.2"], - ["0.0.0-next-900", "900"], - ["1.2.3", "1.2.3"], -])("names build %s", (version, build) => { - expect(betaBuild(version)).toBe(build) -}) - -test("sources a visual with the page's colour, and its still copy for reduced motion", () => { - const visual = { - animated: '', - still: '', - } - - const source = (still: boolean) => - decodeURIComponent(visualSource(visual, { color: "rgb(1, 2, 3)", still }).replace("data:image/svg+xml,", "")) - - expect(source(false)).toBe( - '', - ) - expect(source(true)).toBe('') -}) - -test("lists every change the build carries for the What's new page, and links its history", () => { - const value = embedded({ notes: (n) => (["feat", "fix", "chore"] as const)[n % 3]! }) - const notes = allBetaNotes(value) - - expect(notes.added.map((note) => note.summary)).toEqual( - [30, 27, 24, 21, 18, 15, 12, 9, 6, 3].map((n) => `Change ${n}`), - ) - expect(notes.fixed).toHaveLength(10) - expect([notes.total, notes.more]).toEqual([20, false]) - expect(betaHistory(value)).toBe(`https://github.com/anomalyco/opencode/commits/${sha(30)}`) -}) diff --git a/packages/gui-plugins/src/updater/beta-notes.ts b/packages/gui-plugins/src/updater/beta-notes.ts deleted file mode 100644 index e49c92e9961..00000000000 --- a/packages/gui-plugins/src/updater/beta-notes.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { Option, Schema } from "effect" - -const REPOSITORY = "https://github.com/anomalyco/opencode" - -/** The dialog lists at most this many changes and links the rest. */ -const SHOWN = 10 - -const Note = Schema.Struct({ - type: Schema.Literals(["feat", "fix", "perf"]), - summary: Schema.String, - pr: Schema.optional(Schema.Number), - sha: Schema.String, - author: Schema.optional( - Schema.Struct({ - name: Schema.String, - login: Schema.optional(Schema.String), - avatar: Schema.optional(Schema.String), - }), - ), - description: Schema.optional(Schema.String), - visual: Schema.optional(Schema.Struct({ animated: Schema.String, still: Schema.String })), -}) - -/** What a beta build embeds (packages/desktop/script/beta-notes.ts), newest first. */ -const Embedded = Schema.Struct({ - /** The build's commit. */ - sha: Schema.String, - /** The short shas of the build's newest commits, merges included. */ - commits: Schema.Array(Schema.String), - /** The feat, fix and perf commits among them. */ - notes: Schema.Array(Note), -}) - -export type EmbeddedNotes = typeof Embedded.Type - -/** Decodes the notes a beta build embeds as JSON. Other builds embed none. */ -export const decodeEmbeddedNotes = Schema.decodeUnknownOption(Schema.fromJsonString(Embedded)) - -export type BetaNote = { - readonly sha: string - readonly type: "feat" | "fix" | "perf" - readonly summary: string - /** A sentence or two from the pull request about what it does, read out for its visual. */ - readonly description?: string - /** A small looping SVG of the change, from the pull request's "### Visual", sanitized at build time. */ - readonly visual?: { readonly animated: string; readonly still: string } - readonly pr?: { readonly number: number; readonly url: string } - /** Who made it, with GitHub's public avatar when the build could ask GitHub. */ - readonly author?: { readonly name: string; readonly avatar?: string } -} - -export type BetaNotes = { - /** New features, newest first. */ - readonly added: readonly BetaNote[] - /** Fixes and performance improvements, newest first. */ - readonly fixed: readonly BetaNote[] - /** How many changes the header counts: every one since the previous build, or the listed ones when that is unknown. */ - readonly total: number - /** Some changes since the previous build are not listed. */ - readonly more: boolean -} - -/** - * The changes since the build at `previous`: the notes of the commits newer than it, features first, then fixes, at - * most 10 in all and at least half of them features when there are enough. When `previous` is older than the embedded - * history (or was force-pushed away), the newest notes stand in, with the rest on the What's new page. None without a previous commit, - * or without a change. - */ -export function sliceBetaNotes(embedded: EmbeddedNotes, previous: string | undefined): Option.Option { - if (!previous || previous === embedded.sha) return Option.none() - - const cut = embedded.commits.findIndex((commit) => previous.startsWith(commit)) - const position = new Map(embedded.commits.map((commit, index) => [commit, index])) - const notes = cut === -1 ? embedded.notes : embedded.notes.filter((note) => (position.get(note.sha) ?? cut) < cut) - - if (notes.length === 0) return Option.none() - - const features = notes.filter((note) => note.type === "feat") - const fixes = notes.filter((note) => note.type !== "feat") - // Features get at least half of the list when there are more fixes than fit, fixes whatever features leave. - const added = features.slice(0, Math.max(SHOWN / 2, SHOWN - fixes.length)) - const fixed = fixes.slice(0, SHOWN - added.length) - const listed = added.length + fixed.length - - return Option.some({ - added: added.map(item), - fixed: fixed.map(item), - total: cut === -1 ? listed : notes.length, - more: cut === -1 || listed < notes.length, - }) -} - -/** Every change the build carries, features first, then fixes, newest first: the What's new page. */ -export function allBetaNotes(embedded: EmbeddedNotes): BetaNotes { - return { - added: embedded.notes.filter((note) => note.type === "feat").map(item), - fixed: embedded.notes.filter((note) => note.type !== "feat").map(item), - total: embedded.notes.length, - more: false, - } -} - -/** The build's commit history on GitHub. */ -export const betaHistory = (embedded: EmbeddedNotes) => `${REPOSITORY}/commits/${embedded.sha}` - -/** The beta run number of a version such as `0.0.0-beta-21180`, or the version itself. */ -export function betaBuild(version: string) { - return version.match(/^v?0\.0\.0-(?:beta|next)-(\d+(?:\.\d+)?)$/)?.[1] ?? version -} - -/** - * A visual as an image source. An image never runs scripts or loads anything, and does not inherit the page's colour, so - * `color` sets its currentColor. Reduced motion uses the still copy the build made without the animations. - */ -export function visualSource(visual: NonNullable, input: { color: string; still: boolean }) { - const svg = input.still ? visual.still : visual.animated - - return `data:image/svg+xml,${encodeURIComponent(svg.replace(/]*>/i, (open) => `${open}`))}` -} - -function item(note: typeof Note.Type): BetaNote { - const summary = note.summary.trim().replace(/\.$/, "") - - return { - sha: note.sha, - type: note.type, - summary: summary.charAt(0).toUpperCase() + summary.slice(1), - description: note.description, - visual: note.visual, - pr: note.pr === undefined ? undefined : { number: note.pr, url: `${REPOSITORY}/pull/${note.pr}` }, - author: note.author && { name: note.author.name, avatar: note.author.avatar }, - } -} diff --git a/packages/gui-plugins/src/updater/beta-page.tsx b/packages/gui-plugins/src/updater/beta-page.tsx deleted file mode 100644 index bf9b12ca33f..00000000000 --- a/packages/gui-plugins/src/updater/beta-page.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { createSignal, For, onCleanup, onMount, Show } from "solid-js" -import { createMediaQuery } from "@solid-primitives/media" -import { Option } from "effect" -import { Button } from "@opencode/ui/button" -import { usePlugin } from "@opencode/plugin/gui" -import { Meta, Visual } from "./beta-dialog" -import { allBetaNotes, betaHistory, decodeEmbeddedNotes, type BetaNote, type EmbeddedNotes } from "./beta-notes" -import type definition from "./index" - -/** Settings > What's new in beta: every change this build carries, in the host's settings list markup. */ -export default function BetaNotesPage() { - const ctx = usePlugin() - - return ( - ctx.system.openExternal(href)} - /> - ) -} - -export function BetaNotesView(props: { embedded: EmbeddedNotes | undefined; open: (href: string) => void }) { - const ctx = usePlugin() - // Below 640px a row's control slot wraps under its title, so narrow windows leave the visuals to the dialog. - const wide = createMediaQuery("(min-width: 640px)") - const watch = createWatch() - - return ( - - {(embedded) => { - const notes = allBetaNotes(embedded()) - - return ( - <> -
-
-
-

{ctx.t("beta.title")}

- {ctx.t("beta.page.description")} -
-
- -
-
-
-
- - -
- - ) - }} -
- ) -} - -type Watch = (element: Element, inView: (value: boolean) => void) => () => void - -function Notes(props: { - title: string - notes: readonly BetaNote[] - open: (href: string) => void - wide: boolean - watch: Watch -}) { - return ( - 0}> -
-

{props.title}

-
- - {(note) => ( -
-
-
- {note.summary} -
- -
- -
-
-
- - {(visual) => } - -
- )} -
-
-
-
- ) -} - -/** A row's visual in its control slot, mounted only while the row is near the viewport so off-screen loops stop. */ -function RowVisual(props: { visual: NonNullable; label?: string; watch: Watch }) { - const [visible, setVisible] = createSignal(false) - let slot!: HTMLDivElement - - onMount(() => onCleanup(props.watch(slot, setVisible))) - - return ( - // Inline, so the host's unlayered control-slot width cannot collapse the space before the visual mounts. -
- - - -
- ) -} - -/** One IntersectionObserver for every row on the page; each watched element hears when it nears or leaves the viewport. */ -function createWatch(): Watch { - const rows = new Map void>() - - const observer = new IntersectionObserver( - (entries) => entries.forEach((entry) => rows.get(entry.target)?.(entry.isIntersecting)), - { rootMargin: "200px 0px" }, - ) - - onCleanup(() => observer.disconnect()) - - return (element, inView) => { - rows.set(element, inView) - observer.observe(element) - - return () => { - rows.delete(element) - observer.unobserve(element) - } - } -} diff --git a/packages/gui-plugins/src/updater/i18n/en.ts b/packages/gui-plugins/src/updater/i18n/en.ts index 8057d74bf84..7164f73305d 100644 --- a/packages/gui-plugins/src/updater/i18n/en.ts +++ b/packages/gui-plugins/src/updater/i18n/en.ts @@ -37,13 +37,4 @@ export default { "releaseNotes.action.next": "Next", "releaseNotes.action.hideFuture": "Don't show these in the future", "releaseNotes.media.alt": "Release preview", - "beta.title": "What's new", - "beta.since.one": "Since build {{build}} · {{count}} change", - "beta.since.other": "Since build {{build}} · {{count}} changes", - "beta.added": "New", - "beta.fixed": "Fixes", - "beta.viewAll": "View all", - "beta.page.description": "Every change in this build.", - "beta.github": "History on GitHub", - "beta.done": "Got it", } diff --git a/packages/gui-plugins/src/updater/index.ts b/packages/gui-plugins/src/updater/index.ts index afa2a3a5337..e6fd9394f11 100644 --- a/packages/gui-plugins/src/updater/index.ts +++ b/packages/gui-plugins/src/updater/index.ts @@ -6,7 +6,7 @@ import en from "./i18n/en" const ReleaseNotes = Schema.Struct({ enabled: Schema.Boolean }) -const Seen = Schema.Struct({ version: Schema.optional(Schema.String), commit: Schema.optional(Schema.String) }) +const Seen = Schema.Struct({ version: Schema.optional(Schema.String) }) export default Plugin.define({ stores: { @@ -23,8 +23,7 @@ export default Plugin.define({ }, }, ), - // The version whose What's New was last shown or skipped, and on beta its commit; stored before under the app's - // own key. + // The version whose What's New was last shown or skipped; stored before under the app's own key. seen: Store.global(Seen, {}, "highlights.v1"), }, i18n: builtinCatalog({ diff --git a/packages/gui-plugins/src/updater/renderer.tsx b/packages/gui-plugins/src/updater/renderer.tsx index 47b03edf1b2..fd6eafe4e5d 100644 --- a/packages/gui-plugins/src/updater/renderer.tsx +++ b/packages/gui-plugins/src/updater/renderer.tsx @@ -24,7 +24,6 @@ import type definition from "./index" const setup: Plugin.Setup = (ctx) => { whatsNew(ctx) - betaNotes(ctx) if (!ctx.desktop) return webUpdates(ctx) const updater = ctx.main(Updater) @@ -125,35 +124,8 @@ const setup: Plugin.Setup = (ctx) => { } /** - * What's new in beta as a settings page, to come back to after its dialog closes. Only builds that embed the notes - * have it: beta builds and the desktop dev server. - */ -function betaNotes(ctx: SetupContext) { - if (!import.meta.env.OPENCODE_BETA_NOTES) return - - const Page = lazy(() => import("./beta-page")) - - ctx.add(SettingsPage, { - id: "whats-new", - icon: "outline-list-changelog", - group: "about", - get title() { - return ctx.t("beta.title") - }, - get entries() { - return [{ id: "whats-new", title: ctx.t("beta.title"), keywords: "changelog release notes beta" }] - }, - render: () => ( - - - - ), - }) -} - -/** - * What's New after an update, on every platform: the release highlights since the version last seen, or on beta the - * features and fixes of the beta builds since then. The first run and a disabled What's New only remember the version. + * What's New after an update, on every platform: the release highlights since the version last seen. The first run + * and a disabled What's New only remember the version. */ function whatsNew(ctx: SetupContext) { const seen = ctx.stores.seen @@ -161,10 +133,7 @@ function whatsNew(ctx: SetupContext) { const start = initWhatsNew({ version: ctx.app.version, - commit: import.meta.env.OPENCODE_COMMIT, - channel: ctx.app.channel === "beta" ? "beta" : "stable", previousVersion: seen.value.version, - previousCommit: seen.value.commit, enabled: ctx.stores.releaseNotes.value.enabled, }) @@ -182,9 +151,6 @@ function whatsNew(ctx: SetupContext) { if (effect.type === "mark-seen") return seen.update((draft) => { draft.version = effect.version - - // Beta remembers the build's commit even when nothing shows, so the next update lists what came after it. - if (effect.commit) draft.commit = effect.commit }) if (effect.type === "abort-load") { @@ -194,7 +160,7 @@ function whatsNew(ctx: SetupContext) { return } - if (effect.type === "open") return void open(effect.content, effect.previousVersion) + if (effect.type === "open") return void open(effect.content) timer = setTimeout(() => dispatch({ type: "load.timed-out" }), effect.timeoutMs) void load(effect).then( @@ -204,13 +170,6 @@ function whatsNew(ctx: SetupContext) { } const load = async (effect: Extract): Promise => { - if (effect.channel === "beta") { - const { readBetaNotes } = await import("./beta-dialog") - const notes = readBetaNotes(effect.previousCommit) - - return notes && { channel: "beta", notes } - } - const { loadWhatsNewHighlights } = await import("./whats-new") const highlights = await loadWhatsNewHighlights({ @@ -219,19 +178,11 @@ function whatsNew(ctx: SetupContext) { signal: controller.signal, }) - return { channel: "stable", highlights } + return { highlights } } // The chunk loaded with the notes, so this resolves at once. - const open = async (content: WhatsNewContent, previousVersion: string) => { - if (content.channel === "beta") { - const { openBetaNotes } = await import("./beta-dialog") - - if (!ctx.signal.aborted) openBetaNotes(ctx, content.notes, previousVersion) - - return - } - + const open = async (content: WhatsNewContent) => { const { openWhatsNew } = await import("./whats-new") if (!ctx.signal.aborted) openWhatsNew(ctx, content.highlights) diff --git a/packages/gui-plugins/src/updater/whats-new-machine.test.ts b/packages/gui-plugins/src/updater/whats-new-machine.test.ts index ca4e3351835..5b975787721 100644 --- a/packages/gui-plugins/src/updater/whats-new-machine.test.ts +++ b/packages/gui-plugins/src/updater/whats-new-machine.test.ts @@ -1,5 +1,4 @@ import { describe, expect, test } from "bun:test" -import type { BetaNotes } from "./beta-notes" import { initWhatsNew, transitionWhatsNew, @@ -12,40 +11,17 @@ import { const upgraded: WhatsNewInit = { version: "1.10.0", - commit: undefined, - channel: "stable", previousVersion: "1.9.1", - previousCommit: undefined, enabled: true, } -const beta: WhatsNewInit = { - ...upgraded, - version: "1.10.0-beta.2", - commit: "bbb", - channel: "beta", - previousVersion: "1.10.0-beta.1", - previousCommit: "aaa", -} - const highlights: WhatsNewContent = { - channel: "stable", highlights: [{ title: "Split panes", description: "Work side by side." }], } -const notes: WhatsNewContent = { - channel: "beta", - notes: { - added: [], - fixed: [{ sha: "bbb", type: "fix", summary: "Fixed a crash" }], - total: 1, - more: false, - } satisfies BetaNotes, -} - const loading = (input: WhatsNewInit) => initWhatsNew(input).state -const markSeen: WhatsNewEffect = { type: "mark-seen", version: "1.10.0", commit: undefined } +const markSeen: WhatsNewEffect = { type: "mark-seen", version: "1.10.0" } describe("whats new", () => { test("turning release notes off while they load remembers the release and never opens the dialog", () => { @@ -66,19 +42,13 @@ describe("whats new", () => { expect(transitionWhatsNew(timedOut.state, { type: "loaded", content: highlights }).effects).toEqual([]) }) - test("both channels remember the release as they open its notes, and neither does on dispose", () => { + test("remembers the release as it opens its notes, and not on dispose", () => { expect(transitionWhatsNew(loading(upgraded), { type: "loaded", content: highlights }).effects).toEqual([ { type: "abort-load" }, markSeen, - { type: "open", content: highlights, previousVersion: "1.9.1" }, - ]) - expect(transitionWhatsNew(loading(beta), { type: "loaded", content: notes }).effects).toEqual([ - { type: "abort-load" }, - { type: "mark-seen", version: "1.10.0-beta.2", commit: "bbb" }, - { type: "open", content: notes, previousVersion: "1.10.0-beta.1" }, + { type: "open", content: highlights }, ]) expect(transitionWhatsNew(loading(upgraded), { type: "disposed" }).effects).toEqual([{ type: "abort-load" }]) - expect(transitionWhatsNew(loading(beta), { type: "disposed" }).effects).toEqual([{ type: "abort-load" }]) }) test.each([ @@ -86,11 +56,6 @@ describe("whats new", () => { { name: "the version seen", input: { ...upgraded, previousVersion: "1.10.0" }, effects: [] }, { name: "the first run", input: { ...upgraded, previousVersion: undefined }, effects: [markSeen] }, { name: "release notes off", input: { ...upgraded, enabled: false }, effects: [markSeen] }, - { - name: "release notes off on beta", - input: { ...beta, enabled: false }, - effects: [{ type: "mark-seen", version: "1.10.0-beta.2", commit: "bbb" }], - }, ])("$name loads nothing", (row) => { const start = initWhatsNew(row.input) @@ -98,27 +63,18 @@ describe("whats new", () => { expect(whatsNewLoading(start.state)).toBe(false) }) - test("an upgrade loads the channel's notes with a bounded timeout", () => { - const start = initWhatsNew(beta) + test("an upgrade loads the highlights with a bounded timeout", () => { + const start = initWhatsNew(upgraded) expect(start).toEqual({ - state: { status: "loading", version: "1.10.0-beta.2", commit: "bbb", previousVersion: "1.10.0-beta.1" }, - effects: [ - { - type: "load", - channel: "beta", - previousVersion: "1.10.0-beta.1", - previousCommit: "aaa", - version: "1.10.0-beta.2", - timeoutMs: WHATS_NEW_TIMEOUT_MS, - }, - ], + state: { status: "loading", version: "1.10.0" }, + effects: [{ type: "load", previousVersion: "1.9.1", version: "1.10.0", timeoutMs: WHATS_NEW_TIMEOUT_MS }], }) expect(whatsNewLoading(start.state)).toBe(true) }) test("nothing to show remembers the release; a failed load does not", () => { - const empty = [undefined, { channel: "stable" as const, highlights: [] }] + const empty = [undefined, { highlights: [] }] empty.forEach((content) => expect(transitionWhatsNew(loading(upgraded), { type: "loaded", content }).effects).toEqual([ diff --git a/packages/gui-plugins/src/updater/whats-new-machine.ts b/packages/gui-plugins/src/updater/whats-new-machine.ts index e1e6cf39f50..07fc8a976c8 100644 --- a/packages/gui-plugins/src/updater/whats-new-machine.ts +++ b/packages/gui-plugins/src/updater/whats-new-machine.ts @@ -1,33 +1,22 @@ -import type { BetaNotes } from "./beta-notes" import type { Highlight } from "./whats-new" /** Bounds the dialog's chunk and the changelog request, so a stalled connection never opens a dialog mid-session. */ export const WHATS_NEW_TIMEOUT_MS = 15_000 -/** What the dialog shows: on beta the notes the build embeds, elsewhere the changelog's highlights. */ -export type WhatsNewContent = - | { readonly channel: "beta"; readonly notes: BetaNotes } - | { readonly channel: "stable"; readonly highlights: readonly Highlight[] } +/** What the dialog shows: the changelog's highlights. */ +export type WhatsNewContent = { readonly highlights: readonly Highlight[] } export type WhatsNewInit = { readonly version: string | undefined - readonly commit: string | undefined - readonly channel: WhatsNewContent["channel"] readonly previousVersion: string | undefined - readonly previousCommit: string | undefined /** Release notes are on in Settings. */ readonly enabled: boolean } /** What's New after an update; it only moves forward, so results match on the phase. */ export type WhatsNewState = - /** The dialog's chunk, and on stable the changelog, load for the release `version`. */ - | { - readonly status: "loading" - readonly version: string - readonly commit: string | undefined - readonly previousVersion: string - } + /** The dialog's chunk and the changelog load for the release `version`. */ + | { readonly status: "loading"; readonly version: string } /** Nothing more happens this start: the dialog opened, there was nothing to show, or the load failed or ended. */ | { readonly status: "done" } @@ -43,17 +32,15 @@ export type WhatsNewEvent = export type WhatsNewEffect = | { readonly type: "load" - readonly channel: WhatsNewContent["channel"] readonly previousVersion: string - readonly previousCommit: string | undefined readonly version: string readonly timeoutMs: number } /** Ends the load's timeout and request. */ | { readonly type: "abort-load" } /** Remembers the release, so the next start shows nothing. */ - | { readonly type: "mark-seen"; readonly version: string; readonly commit: string | undefined } - | { readonly type: "open"; readonly content: WhatsNewContent; readonly previousVersion: string } + | { readonly type: "mark-seen"; readonly version: string } + | { readonly type: "open"; readonly content: WhatsNewContent } export type WhatsNewTransition = { readonly state: WhatsNewState; readonly effects: readonly WhatsNewEffect[] } @@ -63,24 +50,21 @@ const NONE: readonly WhatsNewEffect[] = [] /** * Decides at startup: an unchanged version shows nothing; the first run and disabled release notes only remember the - * version; otherwise the channel's notes load. + * version; otherwise the highlights load. */ export function initWhatsNew(input: WhatsNewInit): WhatsNewTransition { const version = input.version if (!version || input.previousVersion === version) return { state: DONE, effects: NONE } - if (!input.previousVersion || !input.enabled) - return { state: DONE, effects: [{ type: "mark-seen", version, commit: input.commit }] } + if (!input.previousVersion || !input.enabled) return { state: DONE, effects: [{ type: "mark-seen", version }] } return { - state: { status: "loading", version, commit: input.commit, previousVersion: input.previousVersion }, + state: { status: "loading", version }, effects: [ { type: "load", - channel: input.channel, previousVersion: input.previousVersion, - previousCommit: input.previousCommit, version, timeoutMs: WHATS_NEW_TIMEOUT_MS, }, @@ -95,19 +79,15 @@ export function transitionWhatsNew(state: WhatsNewState, event: WhatsNewEvent): if (event.type === "load.failed" || event.type === "load.timed-out" || event.type === "disposed") return { state: DONE, effects: [{ type: "abort-load" }] } - const seen: readonly WhatsNewEffect[] = [ - { type: "abort-load" }, - { type: "mark-seen", version: state.version, commit: state.commit }, - ] + const seen: readonly WhatsNewEffect[] = [{ type: "abort-load" }, { type: "mark-seen", version: state.version }] const content = event.type === "loaded" ? event.content : undefined - if (!content || (content.channel === "stable" && content.highlights.length === 0)) - return { state: DONE, effects: seen } + if (!content || content.highlights.length === 0) return { state: DONE, effects: seen } return { state: DONE, - effects: [...seen, { type: "open", content, previousVersion: state.previousVersion }], + effects: [...seen, { type: "open", content }], } }