mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 12:41:37 +03:00
chore: remove beta what's new and visual svgs (#63325)
This commit is contained in:
committed by
GitHub
Unverified
parent
d0cffe13fb
commit
6eac53772b
@@ -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._
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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/<asset>`, 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
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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 <a@b.c> ${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<!-- note -->\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<typeof BetaNotes.embed> => ({
|
||||
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 = `<svg viewBox="0 0 240 72"><rect width="40" height="8" fill="currentColor"><animate attributeName="width" values="40;80;40" dur="3s" repeatCount="indefinite"/></rect></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: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 72"><rect width="40" height="8" fill="currentColor"><animate attributeName="width" values="40;80;40" dur="3s" repeatCount="indefinite"/></rect></svg>`,
|
||||
still: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 72"><rect width="40" height="8" fill="currentColor"/></svg>`,
|
||||
})
|
||||
})
|
||||
|
||||
test("drops scripts, foreign objects, images, handlers, outside links and SMIL that animates a link", () => {
|
||||
const rebuilt = BetaNotes.visual(
|
||||
withVisual(
|
||||
`<svg onload="alert(1)"><!-- note --><script>alert(1)</script><foreignObject><div/></foreignObject><image href="https://example.com/a.png"/><use xlink:href="https://example.com/a.svg#x"/><use xlink:href="#x"/><rect onclick="x()"/><animate attributeName="href" values="https://example.com"/><set attributeName="xlink:href" to="https://example.com"/></svg>`,
|
||||
),
|
||||
)
|
||||
|
||||
expect(rebuilt?.animated).toBe(`<svg xmlns="http://www.w3.org/2000/svg"><use/><use href="#x"/><rect/></svg>`)
|
||||
})
|
||||
|
||||
test("drops a still's keyframes and animations, even important or inline ones", () => {
|
||||
const rebuilt = BetaNotes.visual(
|
||||
withVisual(
|
||||
`<svg><style><![CDATA[.a{fill:red;animation:spin 1s infinite!important}@keyframes spin{from{opacity:0}to{opacity:1}}@media (min-width:1px){.b{transition:opacity 1s}}]]></style><rect class="a" style="animation: spin 1s !important; stroke: blue"/></svg>`,
|
||||
),
|
||||
)
|
||||
|
||||
expect(rebuilt?.still).toBe(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg"><style>.a{fill:red}@media (min-width:1px){.b{}}</style><rect class="a" style="stroke: blue"/></svg>`,
|
||||
)
|
||||
expect(rebuilt?.animated).toContain("@keyframes spin")
|
||||
})
|
||||
|
||||
test.each([
|
||||
["no Visual section", "### What does this PR do?\n\n```svg\n<svg></svg>\n```"],
|
||||
["a root that is not an svg", withVisual("<div><svg></svg></div>")],
|
||||
["more than one svg root", withVisual("<svg></svg><svg></svg>")],
|
||||
["a doctype", withVisual('<!DOCTYPE svg [<!ENTITY x "y">]><svg></svg>')],
|
||||
["an @import", withVisual('<svg><style>@import "https://example.com/a.css";</style></svg>')],
|
||||
["a CSS-escaped @import", withVisual('<svg><style>@\\69mport "https://example.com/a.css";</style></svg>')],
|
||||
["a CSS-escaped url()", withVisual('<svg><rect style="fill:u\\72l(https://example.com/p)"/></svg>')],
|
||||
["an outside url() in an attribute", withVisual('<svg><rect fill="url(https://example.com/p)"/></svg>')],
|
||||
[
|
||||
"an outside url() in an animation",
|
||||
withVisual('<svg><animate attributeName="fill" to="url(https://example.com/p)"/></svg>'),
|
||||
],
|
||||
["an entity-encoded url()", withVisual('<svg><rect fill="url(https://example.com/p)"/></svg>')],
|
||||
["more than 8 KB", withVisual(`<svg>${'<rect width="1"/>'.repeat(600)}</svg>`)],
|
||||
])("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.")
|
||||
})
|
||||
@@ -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<typeof embed>,
|
||||
input: { token: string | undefined; fetch?: Request },
|
||||
): Promise<ReturnType<typeof embed>> {
|
||||
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]*?-->|```[\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<globalThis.Response>
|
||||
|
||||
/** 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<BetaNote["author"]> {
|
||||
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<BetaNote, "sha"> = { 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),
|
||||
)
|
||||
@@ -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 <img>. The input is parsed as XML and serialized again from scratch: exactly one <svg> 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<typeof parseDocument>["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 <svg> 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 || /<!(doctype|entity|element|attlist)/i.test(input)) return undefined
|
||||
|
||||
const document = parseDocument(input.replace(/^\s*<\?xml[^>]*\?>/, ""), {
|
||||
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}</${name}>` : `<${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, ">").replace(/"/g, """)
|
||||
}
|
||||
Vendored
-6
@@ -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
|
||||
}
|
||||
@@ -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(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" fill="hsl(${hue} 45% 62%)"/><circle cx="24" cy="19" r="9" fill="hsl(${hue} 40% 88%)"/><rect x="9" y="32" width="30" height="20" rx="10" fill="hsl(${hue} 40% 88%)"/></svg>`,
|
||||
)}`
|
||||
|
||||
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<string, Author>
|
||||
|
||||
// Example visuals as a build embeds them from a pull request's "### Visual": animated, and the still for reduced motion.
|
||||
const visuals = {
|
||||
dock: {
|
||||
animated:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 72" fill="none"><style>.d{transform-box:fill-box;transform-origin:50% 100%;animation:g 4s cubic-bezier(.2,.8,.2,1) infinite}.c{animation:f 4s ease-out infinite}@keyframes g{0%,12%{transform:scaleY(.3);opacity:0}28%,72%{transform:none;opacity:1}88%,100%{transform:scaleY(.3);opacity:0}}@keyframes f{0%,26%{opacity:0}36%,70%{opacity:1}80%,100%{opacity:0}}</style><g class="d"><rect x="56" y="10" width="128" height="30" rx="6" fill="currentColor" fill-opacity=".08" stroke="currentColor" stroke-opacity=".35"/><g class="c" fill="currentColor"><rect x="64" y="17" width="64" height="4" rx="2" fill-opacity=".55"/><rect x="64" y="25" width="40" height="4" rx="2" fill-opacity=".25"/><rect x="140" y="27" width="18" height="8" rx="4" fill-opacity=".25"/><rect x="161" y="27" width="16" height="8" rx="4" fill-opacity=".6"/></g></g><rect x="48" y="40" width="144" height="24" rx="8" fill="currentColor" fill-opacity=".06" stroke="currentColor" stroke-opacity=".5"/><rect x="58" y="50" width="52" height="4" rx="2" fill="currentColor" fill-opacity=".35"/><circle cx="180" cy="52" r="6" fill="currentColor" fill-opacity=".7"/></svg>',
|
||||
still:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 72" fill="none"><style>.d{transform-box:fill-box;transform-origin:50% 100%}.c{}</style><g class="d"><rect x="56" y="10" width="128" height="30" rx="6" fill="currentColor" fill-opacity=".08" stroke="currentColor" stroke-opacity=".35"/><g class="c" fill="currentColor"><rect x="64" y="17" width="64" height="4" rx="2" fill-opacity=".55"/><rect x="64" y="25" width="40" height="4" rx="2" fill-opacity=".25"/><rect x="140" y="27" width="18" height="8" rx="4" fill-opacity=".25"/><rect x="161" y="27" width="16" height="8" rx="4" fill-opacity=".6"/></g></g><rect x="48" y="40" width="144" height="24" rx="8" fill="currentColor" fill-opacity=".06" stroke="currentColor" stroke-opacity=".5"/><rect x="58" y="50" width="52" height="4" rx="2" fill="currentColor" fill-opacity=".35"/><circle cx="180" cy="52" r="6" fill="currentColor" fill-opacity=".7"/></svg>',
|
||||
},
|
||||
spinners: {
|
||||
animated:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 72" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><style>.s{transform-box:view-box;animation:r 1.6s linear infinite}.p{animation:p 1.6s ease-in-out infinite}@keyframes r{to{transform:rotate(360deg)}}@keyframes p{0%,100%{opacity:.08}50%{opacity:.3}}</style><path class="p" d="M44 36h152" stroke-width="1" stroke-dasharray="2 4"/><g stroke-opacity=".15"><circle cx="60" cy="36" r="11"/><circle cx="120" cy="36" r="11"/><circle cx="180" cy="36" r="11"/></g><g stroke-opacity=".8"><path class="s" style="transform-origin:60px 36px" d="M60 25a11 11 0 0 1 11 11"/><path class="s" style="transform-origin:120px 36px" d="M120 25a11 11 0 0 1 11 11"/><path class="s" style="transform-origin:180px 36px" d="M180 25a11 11 0 0 1 11 11"/></g></svg>',
|
||||
still:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 72" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><style>.s{transform-box:view-box}.p{}</style><path class="p" d="M44 36h152" stroke-width="1" stroke-dasharray="2 4"/><g stroke-opacity=".15"><circle cx="60" cy="36" r="11"/><circle cx="120" cy="36" r="11"/><circle cx="180" cy="36" r="11"/></g><g stroke-opacity=".8"><path class="s" style="transform-origin:60px 36px" d="M60 25a11 11 0 0 1 11 11"/><path class="s" style="transform-origin:120px 36px" d="M120 25a11 11 0 0 1 11 11"/><path class="s" style="transform-origin:180px 36px" d="M180 25a11 11 0 0 1 11 11"/></g></svg>',
|
||||
},
|
||||
palette: {
|
||||
animated:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 72" fill="currentColor"><style>.t{transform-box:fill-box;transform-origin:0 50%;animation:t 4s steps(6,end) infinite}.h{animation:h 4s ease-out infinite}.o{animation:o 4s ease-out infinite}@keyframes t{0%,8%{transform:scaleX(0)}38%,84%{transform:none}96%,100%{transform:scaleX(0)}}@keyframes h{0%,40%{opacity:0}50%,84%{opacity:1}94%,100%{opacity:0}}@keyframes o{0%,40%{opacity:.45}50%,84%{opacity:1}94%,100%{opacity:.45}}</style><rect x="52" y="4" width="136" height="64" rx="7" fill-opacity=".06" stroke="currentColor" stroke-opacity=".35"/><rect class="t" x="62" y="13" width="44" height="5" rx="2.5" fill-opacity=".6"/><rect x="52" y="24" width="136" height="1" fill-opacity=".15"/><rect x="62" y="31" width="22" height="3" rx="1.5" fill-opacity=".25"/><rect class="h" x="56" y="38" width="128" height="12" rx="4" fill-opacity=".12"/><circle class="o" cx="66" cy="44" r="3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-opacity=".55"/><rect class="o" x="74" y="42" width="52" height="4" rx="2" fill-opacity=".55"/><rect x="62" y="56" width="8" height="4" rx="2" fill-opacity=".2"/><rect x="74" y="56" width="70" height="4" rx="2" fill-opacity=".2"/></svg>',
|
||||
still:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 72" fill="currentColor"><style>.t{transform-box:fill-box;transform-origin:0 50%}.h{}.o{}</style><rect x="52" y="4" width="136" height="64" rx="7" fill-opacity=".06" stroke="currentColor" stroke-opacity=".35"/><rect class="t" x="62" y="13" width="44" height="5" rx="2.5" fill-opacity=".6"/><rect x="52" y="24" width="136" height="1" fill-opacity=".15"/><rect x="62" y="31" width="22" height="3" rx="1.5" fill-opacity=".25"/><rect class="h" x="56" y="38" width="128" height="12" rx="4" fill-opacity=".12"/><circle class="o" cx="66" cy="44" r="3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-opacity=".55"/><rect class="o" x="74" y="42" width="52" height="4" rx="2" fill-opacity=".55"/><rect x="62" y="56" width="8" height="4" rx="2" fill-opacity=".2"/><rect x="74" y="56" width="70" height="4" rx="2" fill-opacity=".2"/></svg>',
|
||||
},
|
||||
sidebar: {
|
||||
animated:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 72" fill="currentColor"><style>.b{animation:b 4.5s cubic-bezier(.45,0,.2,1) infinite}.m{animation:m 4.5s cubic-bezier(.45,0,.2,1) infinite}@keyframes b{0%,15%{transform:translateX(-44px)}40%,70%{transform:none}95%,100%{transform:translateX(-44px)}}@keyframes m{0%,15%{transform:translateX(-44px)}40%,70%{transform:none}95%,100%{transform:translateX(-44px)}}</style><defs><clipPath id="w"><rect x="44" y="6" width="152" height="60" rx="6"/></clipPath></defs><rect x="44" y="6" width="152" height="60" rx="6" fill-opacity=".05" stroke="currentColor" stroke-opacity=".35"/><g clip-path="url(#w)"><g class="b"><rect x="44" y="6" width="44" height="60" fill-opacity=".1"/><rect x="52" y="16" width="26" height="4" rx="2" fill-opacity=".5"/><rect x="52" y="26" width="20" height="4" rx="2" fill-opacity=".25"/><rect x="52" y="36" width="24" height="4" rx="2" fill-opacity=".25"/></g><g class="m"><rect x="88" y="6" width="1" height="60" fill-opacity=".25"/><rect x="100" y="16" width="64" height="5" rx="2.5" fill-opacity=".55"/><rect x="100" y="27" width="84" height="4" rx="2" fill-opacity=".25"/><rect x="100" y="36" width="72" height="4" rx="2" fill-opacity=".25"/><rect x="100" y="45" width="80" height="4" rx="2" fill-opacity=".25"/></g></g></svg>',
|
||||
still:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 72" fill="currentColor"><style>.b{}.m{}</style><defs><clipPath id="w"><rect x="44" y="6" width="152" height="60" rx="6"/></clipPath></defs><rect x="44" y="6" width="152" height="60" rx="6" fill-opacity=".05" stroke="currentColor" stroke-opacity=".35"/><g clip-path="url(#w)"><g class="b"><rect x="44" y="6" width="44" height="60" fill-opacity=".1"/><rect x="52" y="16" width="26" height="4" rx="2" fill-opacity=".5"/><rect x="52" y="26" width="20" height="4" rx="2" fill-opacity=".25"/><rect x="52" y="36" width="24" height="4" rx="2" fill-opacity=".25"/></g><g class="m"><rect x="88" y="6" width="1" height="60" fill-opacity=".25"/><rect x="100" y="16" width="64" height="5" rx="2.5" fill-opacity=".55"/><rect x="100" y="27" width="84" height="4" rx="2" fill-opacity=".25"/><rect x="100" y="36" width="72" height="4" rx="2" fill-opacity=".25"/><rect x="100" y="45" width="80" height="4" rx="2" fill-opacity=".25"/></g></g></svg>',
|
||||
},
|
||||
}
|
||||
|
||||
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(() => (
|
||||
<DialogBetaNotes
|
||||
notes={notes}
|
||||
previous={props.previous ?? "0.0.0-beta-21180"}
|
||||
dialog={{ close: () => dialog.close() }}
|
||||
open={(href) => window.open(href, "_blank")}
|
||||
viewAll={() => {
|
||||
dialog.close()
|
||||
setPage(true)
|
||||
}}
|
||||
/>
|
||||
))
|
||||
|
||||
onMount(open)
|
||||
|
||||
return (
|
||||
<Show when={page()} fallback={<Button onClick={open}>Open What's new in beta</Button>}>
|
||||
<SettingsPanel>
|
||||
<BetaNotesView embedded={embedded(props.picked)} open={(href) => window.open(href, "_blank")} />
|
||||
</SettingsPanel>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
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: () => <Fixture picked={commits.slice(0, 8)} /> }
|
||||
|
||||
/** More than ten changes: View all opens the What's new page. */
|
||||
export const Many = {
|
||||
render: () => <Fixture picked={commits} previous="0.0.0-beta-21012" />,
|
||||
}
|
||||
|
||||
/** Built without GitHub: commit authors as initials, and kind icons where the visuals would be. */
|
||||
export const Offline = { render: () => <Fixture picked={offline(commits.slice(0, 8))} /> }
|
||||
|
||||
/** Only fixes, so the New section is not shown. */
|
||||
export const FixesOnly = {
|
||||
render: () => <Fixture picked={commits.filter(([type]) => 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: () => <Fixture picked={commits} previous="0.0.0-beta-20500" previousCommit={sha(5000)} />,
|
||||
}
|
||||
|
||||
/** Settings > What's new in beta: every change the build carries, from Settings or the dialog's View all. */
|
||||
export const Page = {
|
||||
render: () => (
|
||||
<SettingsPanel>
|
||||
<BetaNotesView embedded={embedded(commits)} open={(href) => window.open(href, "_blank")} />
|
||||
</SettingsPanel>
|
||||
),
|
||||
}
|
||||
@@ -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<typeof definition>, notes: BetaNotes, previous: string) {
|
||||
ctx.dialogs.open((dialog) => (
|
||||
<DialogBetaNotes
|
||||
notes={notes}
|
||||
previous={previous}
|
||||
dialog={dialog}
|
||||
open={(href) => 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<typeof definition>()
|
||||
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 (
|
||||
<Dialog fit containerClass="w-[min(calc(100vw-32px),520px)]!">
|
||||
<DialogHeader>
|
||||
<DialogTitleGroup
|
||||
title={ctx.t("beta.title")}
|
||||
description={ctx.plural("beta.since", props.notes.total, { build: betaBuild(props.previous) })}
|
||||
/>
|
||||
</DialogHeader>
|
||||
<DialogBody class="min-h-0">
|
||||
<div
|
||||
ref={list}
|
||||
class="flex max-h-[min(calc(100vh-220px),440px)] flex-col gap-6 overflow-y-auto px-4 pb-2"
|
||||
style={{
|
||||
"mask-image":
|
||||
fade().top || fade().bottom
|
||||
? `linear-gradient(to bottom, transparent, #000 ${fade().top}px, #000 calc(100% - ${fade().bottom}px), transparent)`
|
||||
: undefined,
|
||||
}}
|
||||
onScroll={measure}
|
||||
>
|
||||
<Section title={ctx.t("beta.added")} notes={props.notes.added} open={props.open} />
|
||||
<Section title={ctx.t("beta.fixed")} notes={props.notes.fixed} open={props.open} />
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Show when={props.notes.more}>
|
||||
<Button type="button" variant="ghost" class="-ms-2.5 me-auto" onClick={() => props.viewAll()}>
|
||||
{ctx.t("beta.viewAll")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Button type="button" variant="contrast" autofocus onClick={() => props.dialog.close()}>
|
||||
{ctx.t("beta.done")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<Show when={props.notes.length > 0}>
|
||||
<section class="flex flex-col gap-3">
|
||||
<h2 class="m-0 text-12-medium leading-4 text-v2-text-text-muted">{props.title}</h2>
|
||||
<ul class="m-0 flex list-none flex-col gap-4 p-0">
|
||||
<For each={props.notes}>
|
||||
{(note) => (
|
||||
<li class="flex min-w-0 items-center gap-4">
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1 [overflow-wrap:anywhere]">
|
||||
<p class="m-0 text-[13px] leading-5 font-[500] tracking-[-0.04px] text-v2-text-text-base">
|
||||
{note.summary}
|
||||
</p>
|
||||
<Meta note={note} open={props.open} />
|
||||
</div>
|
||||
<Show when={note.visual}>{(visual) => <Visual visual={visual()} label={note.description} />}</Show>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</section>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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<BetaNote["visual"]>; label?: string }) {
|
||||
const [color, setColor] = createSignal<string>()
|
||||
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 (
|
||||
<span ref={frame} class="block h-9 w-[120px] shrink-0 rounded-[6px] bg-current/[0.05] text-v2-icon-icon-base">
|
||||
<Show when={color()}>
|
||||
{(value) => (
|
||||
<img
|
||||
src={visualSource(props.visual, { color: value(), still: prefersReducedMotion() })}
|
||||
alt={props.label ?? ""}
|
||||
width="120"
|
||||
height="36"
|
||||
draggable={false}
|
||||
class="block size-full object-contain"
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<Show when={props.note.author || props.note.pr}>
|
||||
<div
|
||||
class={`flex min-w-0 flex-wrap items-center gap-x-1.5 ${props.class ?? "text-[12px] leading-4 font-[400] text-v2-text-text-faint"}`}
|
||||
>
|
||||
<Show when={props.note.author}>
|
||||
{(author) => (
|
||||
<span class="flex min-w-0 items-center gap-1.5">
|
||||
<Avatar name={author().name} src={author().avatar} />
|
||||
{author().name}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={props.note.pr}>
|
||||
{(pr) => (
|
||||
<>
|
||||
<Show when={props.note.author}>
|
||||
<span aria-hidden="true">·</span>
|
||||
</Show>
|
||||
<a
|
||||
href={pr().url}
|
||||
class="rounded-[4px] tabular-nums text-v2-text-text-faint underline-offset-2 hover:text-v2-text-text-base hover:underline focus-visible:underline focus-visible:outline-none"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
props.open(pr().url)
|
||||
}}
|
||||
>
|
||||
#{pr().number}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="relative grid size-3.5 shrink-0 place-items-center overflow-hidden rounded-full bg-v2-background-bg-layer-02 text-[8px] leading-none font-[530] text-v2-text-text-muted uppercase after:absolute after:inset-0 after:rounded-full after:shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)]"
|
||||
>
|
||||
{Array.from(props.name.trim())[0]}
|
||||
<Show when={props.src}>
|
||||
{(src) => (
|
||||
<img
|
||||
src={src()}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
referrerPolicy="no-referrer"
|
||||
class="absolute inset-0 size-full object-cover opacity-0 transition-opacity duration-200 ease-out"
|
||||
classList={{ "opacity-100": loaded() }}
|
||||
onLoad={() => setLoaded(true)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -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: '<svg viewBox="0 0 240 72"><rect><animate/></rect></svg>',
|
||||
still: '<svg viewBox="0 0 240 72"><rect/></svg>',
|
||||
}
|
||||
|
||||
const source = (still: boolean) =>
|
||||
decodeURIComponent(visualSource(visual, { color: "rgb(1, 2, 3)", still }).replace("data:image/svg+xml,", ""))
|
||||
|
||||
expect(source(false)).toBe(
|
||||
'<svg viewBox="0 0 240 72"><style>svg{color:rgb(1, 2, 3)}</style><rect><animate/></rect></svg>',
|
||||
)
|
||||
expect(source(true)).toBe('<svg viewBox="0 0 240 72"><style>svg{color:rgb(1, 2, 3)}</style><rect/></svg>')
|
||||
})
|
||||
|
||||
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)}`)
|
||||
})
|
||||
@@ -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<BetaNotes> {
|
||||
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<BetaNote["visual"]>, input: { color: string; still: boolean }) {
|
||||
const svg = input.still ? visual.still : visual.animated
|
||||
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg.replace(/<svg\b[^>]*>/i, (open) => `${open}<style>svg{color:${input.color}}</style>`))}`
|
||||
}
|
||||
|
||||
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 },
|
||||
}
|
||||
}
|
||||
@@ -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<typeof definition>()
|
||||
|
||||
return (
|
||||
<BetaNotesView
|
||||
embedded={Option.getOrUndefined(decodeEmbeddedNotes(import.meta.env.OPENCODE_BETA_NOTES))}
|
||||
open={(href) => ctx.system.openExternal(href)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function BetaNotesView(props: { embedded: EmbeddedNotes | undefined; open: (href: string) => void }) {
|
||||
const ctx = usePlugin<typeof definition>()
|
||||
// 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 (
|
||||
<Show when={props.embedded}>
|
||||
{(embedded) => {
|
||||
const notes = allBetaNotes(embedded())
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-tab-header">
|
||||
<div class="settings-tab-header-row">
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<h2 class="settings-tab-title">{ctx.t("beta.title")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">{ctx.t("beta.page.description")}</span>
|
||||
</div>
|
||||
<div class="settings-tab-header-actions">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
class="whitespace-nowrap"
|
||||
onClick={() => props.open(betaHistory(embedded()))}
|
||||
>
|
||||
{ctx.t("beta.github")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-tab-body settings-tab-body--sectioned">
|
||||
<Notes title={ctx.t("beta.added")} notes={notes.added} open={props.open} wide={wide()} watch={watch} />
|
||||
<Notes title={ctx.t("beta.fixed")} notes={notes.fixed} open={props.open} wide={wide()} watch={watch} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Show when={props.notes.length > 0}>
|
||||
<section class="settings-section" aria-label={props.title}>
|
||||
<h3 class="settings-section-title">{props.title}</h3>
|
||||
<div data-component="settings-list" data-variant="catalog">
|
||||
<For each={props.notes}>
|
||||
{(note) => (
|
||||
<div data-component="settings-row">
|
||||
<div data-slot="settings-row-copy">
|
||||
<div data-slot="settings-row-title" class="[overflow-wrap:anywhere]">
|
||||
{note.summary}
|
||||
</div>
|
||||
<Show when={note.author || note.pr}>
|
||||
<div data-slot="settings-row-description">
|
||||
<Meta note={note} open={props.open} class="" />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.wide && note.visual}>
|
||||
{(visual) => <RowVisual visual={visual()} label={note.description} watch={props.watch} />}
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</section>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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<BetaNote["visual"]>; 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.
|
||||
<div ref={slot} data-slot="settings-row-control" style={{ width: "120px", height: "36px" }}>
|
||||
<Show when={visible()}>
|
||||
<Visual visual={props.visual} label={props.label} />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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<Element, (value: boolean) => 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -24,7 +24,6 @@ import type definition from "./index"
|
||||
|
||||
const setup: Plugin.Setup<typeof definition> = (ctx) => {
|
||||
whatsNew(ctx)
|
||||
betaNotes(ctx)
|
||||
|
||||
if (!ctx.desktop) return webUpdates(ctx)
|
||||
const updater = ctx.main(Updater)
|
||||
@@ -125,35 +124,8 @@ const setup: Plugin.Setup<typeof definition> = (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<typeof definition>) {
|
||||
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: () => (
|
||||
<Suspense>
|
||||
<Page />
|
||||
</Suspense>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<typeof definition>) {
|
||||
const seen = ctx.stores.seen
|
||||
@@ -161,10 +133,7 @@ function whatsNew(ctx: SetupContext<typeof definition>) {
|
||||
|
||||
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<typeof definition>) {
|
||||
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<typeof definition>) {
|
||||
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<typeof definition>) {
|
||||
}
|
||||
|
||||
const load = async (effect: Extract<WhatsNewEffect, { type: "load" }>): Promise<WhatsNewContent | undefined> => {
|
||||
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<typeof definition>) {
|
||||
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)
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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 }],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user