mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
feat(desktop): open What's new in beta from the command palette and Settings (#57940)
This commit is contained in:
committed by
GitHub
Unverified
parent
5a023ed662
commit
548ea5deb1
@@ -55,9 +55,14 @@ export default defineConfig(async ({ command }) => {
|
||||
const channel = resolveChannel()
|
||||
const nodePtyPkg = nodePtyPackage()
|
||||
|
||||
// Beta builds carry their commit and recent history for the updater's "What's new in beta"; other channels none.
|
||||
// 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 and command can be tried; other channels none.
|
||||
const betaNotes =
|
||||
channel === "beta" ? await BetaNotes.enrich(BetaNotes.embed(), { token: process.env.GITHUB_TOKEN }) : undefined
|
||||
channel === "beta"
|
||||
? await BetaNotes.enrich(BetaNotes.embed(), { token: process.env.GITHUB_TOKEN })
|
||||
: command === "serve"
|
||||
? BetaNotes.embed()
|
||||
: undefined
|
||||
|
||||
return {
|
||||
main: {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { Option } from "effect"
|
||||
import { onMount } from "solid-js"
|
||||
import { createSignal, onMount, Show } from "solid-js"
|
||||
import { SettingsPanel } from "../../../stories/mocks/gui-extensions/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 }
|
||||
@@ -128,8 +130,10 @@ const embedded = (picked: typeof commits): EmbeddedNotes => ({
|
||||
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 = () =>
|
||||
@@ -139,12 +143,22 @@ function Fixture(props: { picked: typeof commits; previous?: string; previousCom
|
||||
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 <Button onClick={open}>Open What's new in beta</Button>
|
||||
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 {
|
||||
@@ -163,7 +177,7 @@ export default {
|
||||
/** A typical update: a few features and fixes. */
|
||||
export const Typical = { render: () => <Fixture picked={commits.slice(0, 8)} /> }
|
||||
|
||||
/** More than ten changes: the rest are linked on GitHub. */
|
||||
/** 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" />,
|
||||
}
|
||||
@@ -176,7 +190,16 @@ 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 the rest are linked. */
|
||||
/** 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 ⌘K, Settings or the dialog's View all. */
|
||||
export const Page = {
|
||||
render: () => (
|
||||
<SettingsPanel>
|
||||
<BetaNotesView embedded={embedded(commits)} open={(href) => window.open(href, "_blank")} />
|
||||
</SettingsPanel>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSignal, For, onMount, Show } from "solid-js"
|
||||
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"
|
||||
@@ -38,18 +38,23 @@ export function showBetaNotes(
|
||||
previous={input.previous}
|
||||
dialog={dialog}
|
||||
open={(href) => ctx.system.openExternal(href)}
|
||||
viewAll={() => {
|
||||
dialog.close()
|
||||
ctx.layout.settings("whats-new")
|
||||
}}
|
||||
/>
|
||||
),
|
||||
{ replace: true },
|
||||
)
|
||||
}
|
||||
|
||||
/** What's new in beta: the listed features and fixes, and a link to the whole range when some are not listed. */
|
||||
/** 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 = useExtension<typeof definition>()
|
||||
const [fade, setFade] = createSignal({ top: 0, bottom: 0 })
|
||||
@@ -93,11 +98,9 @@ export function DialogBetaNotes(props: {
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Show when={props.notes.more}>
|
||||
{(href) => (
|
||||
<Button type="button" variant="ghost" class="-ms-2.5 me-auto" onClick={() => props.open(href())}>
|
||||
{ctx.t("beta.viewAll")}
|
||||
</Button>
|
||||
)}
|
||||
<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")}
|
||||
@@ -112,7 +115,7 @@ 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 }) {
|
||||
export 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">
|
||||
@@ -142,7 +145,15 @@ function Visual(props: { visual: NonNullable<BetaNote["visual"]>; label?: string
|
||||
const [color, setColor] = createSignal<string>()
|
||||
let frame!: HTMLSpanElement
|
||||
|
||||
onMount(() => setColor(getComputedStyle(frame).color))
|
||||
// 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">
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Option } from "effect"
|
||||
import { betaBuild, decodeEmbeddedNotes, sliceBetaNotes, visualSource, type EmbeddedNotes } from "./beta-notes"
|
||||
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")
|
||||
|
||||
@@ -21,8 +29,6 @@ function embedded(input: { notes: (n: number) => "feat" | "fix" | "chore" }): Em
|
||||
}
|
||||
}
|
||||
|
||||
const compare = (from: number) => `https://github.com/anomalyco/opencode/compare/${sha(from)}...${sha(30)}`
|
||||
|
||||
test("lists the notes after the previous commit, features first, humanized, with pull request links", () => {
|
||||
const notes = sliceBetaNotes(
|
||||
{
|
||||
@@ -59,16 +65,16 @@ test("lists the notes after the previous commit, features first, humanized, with
|
||||
{ sha: short(28), type: "perf", summary: "Cache the model list" },
|
||||
],
|
||||
total: 3,
|
||||
more: undefined,
|
||||
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, undefined],
|
||||
["found, more than 10", sha(5), (n: number) => (n % 2 ? "fix" : "feat"), [5, 5], 25, compare(5)],
|
||||
["found, mostly features", sha(10), (n: number) => (n < 28 ? "feat" : "fix"), [7, 3], 20, compare(10)],
|
||||
["missing from the history", sha(99), (n: number) => (n % 2 ? "fix" : "feat"), [5, 5], 10, compare(99)],
|
||||
["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))
|
||||
|
||||
@@ -115,3 +121,15 @@ test("sources a visual with the page's colour, and its still copy for reduced mo
|
||||
)
|
||||
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)}`)
|
||||
})
|
||||
|
||||
@@ -56,14 +56,14 @@ export type BetaNotes = {
|
||||
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
|
||||
/** The GitHub compare page of the whole range, when some changes are not listed. */
|
||||
readonly more?: string
|
||||
/** 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 linked. None without a previous commit,
|
||||
* 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> {
|
||||
@@ -86,10 +86,23 @@ export function sliceBetaNotes(embedded: EmbeddedNotes, previous: string | undef
|
||||
added: added.map(item),
|
||||
fixed: fixed.map(item),
|
||||
total: cut === -1 ? listed : notes.length,
|
||||
more: cut === -1 || listed < notes.length ? `${REPOSITORY}/compare/${previous}...${embedded.sha}` : undefined,
|
||||
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
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Show } from "solid-js"
|
||||
import { Option } from "effect"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { useExtension } from "../sdk"
|
||||
import { Section } from "./beta-dialog"
|
||||
import { allBetaNotes, betaHistory, decodeEmbeddedNotes, type EmbeddedNotes } from "./beta-notes"
|
||||
import type definition from "./index"
|
||||
|
||||
/** Settings > What's new in beta: every change this build carries, as the dialog shows them. */
|
||||
export default function BetaNotesPage() {
|
||||
const ctx = useExtension<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 = useExtension<typeof definition>()
|
||||
|
||||
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 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"
|
||||
class="-me-2.5"
|
||||
onClick={() => props.open(betaHistory(embedded()))}
|
||||
>
|
||||
{ctx.t("beta.github")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-tab-body settings-tab-body--sectioned">
|
||||
<Section title={ctx.t("beta.added")} notes={notes.added} open={props.open} />
|
||||
<Section title={ctx.t("beta.fixed")} notes={notes.fixed} open={props.open} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -34,6 +34,8 @@ export default {
|
||||
"beta.since.other": "Since build {{build}} · {{count}} changes",
|
||||
"beta.added": "New",
|
||||
"beta.fixed": "Fixes",
|
||||
"beta.viewAll": "View all on GitHub",
|
||||
"beta.viewAll": "View all",
|
||||
"beta.page.description": "Every change in this build.",
|
||||
"beta.github": "History on GitHub",
|
||||
"beta.done": "Got it",
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type definition from "./index"
|
||||
|
||||
const setup: Setup<typeof definition> = (ctx) => {
|
||||
whatsNew(ctx)
|
||||
betaNotes(ctx)
|
||||
|
||||
if (!ctx.desktop) return
|
||||
const updater = ctx.uses.updater
|
||||
@@ -86,6 +87,41 @@ const setup: Setup<typeof definition> = (ctx) => {
|
||||
createKeyed(updater, (client) => void client.on("check", () => act("check")))
|
||||
}
|
||||
|
||||
/**
|
||||
* What's new in beta as a settings page and a command, so it is a keystroke away 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: "flask",
|
||||
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>
|
||||
),
|
||||
})
|
||||
|
||||
ctx.add(Command, {
|
||||
id: "whats-new",
|
||||
get title() {
|
||||
return ctx.t("beta.title")
|
||||
},
|
||||
keywords: "changelog release notes beta",
|
||||
run: () => ctx.layout.settings("whats-new"),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { JSX } from "solid-js"
|
||||
// Extension settings pages render inside the app's settings panel, which brings these styles and caps the width.
|
||||
import "@/settings/settings.css"
|
||||
|
||||
export function SettingsPanel(props: { children: JSX.Element }) {
|
||||
return <div class="settings-panel h-screen max-w-[800px] bg-v2-background-bg-base">{props.children}</div>
|
||||
}
|
||||
Reference in New Issue
Block a user