diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 7b2fe937e..73bfb2d8b 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -251,7 +251,28 @@ jobs: core.setFailed(`latest.json would miss ${missing.join(", ")}`); return; } - const manifest = { version: tag.replace(/^v/, ""), notes: "", pub_date: new Date().toISOString(), platforms }; + // The update's notes: the release body without its Download section. That + // section only matters on the release page, and What's new follows it. + const { data: release } = await github.rest.repos.getRelease({ owner, repo, release_id }); + const withoutDownloadSection = (body) => { + const kept = []; + let skip = 0; + for (const line of body.split(/\r?\n/)) { + const heading = /^(#{1,6})\s+(.*)$/.exec(line); + if (heading) { + const level = heading[1].length; + if (!skip && level <= 3 && /^download$/i.test(heading[2].trim())) { + skip = level; + continue; + } + if (skip && level <= skip) skip = 0; + } + if (!skip) kept.push(line); + } + return kept.join("\n").trim(); + }; + const notes = withoutDownloadSection(String(release.body ?? "")); + const manifest = { version: tag.replace(/^v/, ""), notes, pub_date: new Date().toISOString(), platforms }; const old = assets.find((a) => a.name === "latest.json"); if (old) await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: old.id }); await github.rest.repos.uploadReleaseAsset({ diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 82f5a12f5..3827718ca 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -453,6 +453,10 @@ pub struct GeneralSettings { /// Where updates come from: `auto` (the CNB mirror for the Chinese UI, else GitHub) | `github` | `cnb`. pub update_source: String, pub auto_check_updates: bool, + /// System Settings › Updates: open the What's New page after the app starts a new version. + pub show_whats_new_after_update: bool, + /// The version the app last started with, so the first start after an update is recognisable. + pub last_run_version: String, } impl Default for GeneralSettings { @@ -471,6 +475,8 @@ impl Default for GeneralSettings { project_name: String::new(), update_source: "auto".into(), auto_check_updates: true, + show_whats_new_after_update: true, + last_run_version: String::new(), } } } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 8d9659722..12ad98c62 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -489,6 +489,7 @@ pub fn builder() -> tauri_specta::Builder { window::app_update_status, window::app_update_announcement, window::app_install_update, + window::app_release_notes, debug::debug_exception_filters, debug::debug_set_exception_breakpoints, debug::debug_set_variable, diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index dcb7ed412..be5b23dca 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -608,8 +608,66 @@ pub async fn app_install_update( result } +/// Release notes of `version` from the update manifest (`latest.json`), read +/// through the configured source. An install records the notes it saw, so this +/// is only for an update installed outside the app. `Ok(None)` when the manifest +/// covers another version or carries no notes. +#[tauri::command] +#[specta::specta] +pub async fn app_release_notes(app: AppHandle, version: String, source: UpdateSource) -> IdeResult> { + let endpoints = match update_endpoint_env() { + Some(url) => vec![url], + None => source_endpoints(config_endpoints(&app), source, is_nightly(&app.package_info().version.to_string())), + }; + let client = ide_http::client::shared()?; + for endpoint in endpoints { + // A hanging endpoint must not leave the page on "Fetching release notes…". + let fetched = tokio::time::timeout(std::time::Duration::from_secs(15), async { + let Ok(response) = client.get(&endpoint).send().await else { + return None; + }; + if !response.status().is_success() { + return None; + } + response.text().await.ok() + }) + .await; + let Ok(Some(text)) = fetched else { continue }; + if let Some(notes) = notes_for(&text, &version) { + return Ok(Some(notes)); + } + } + Ok(None) +} + +/// The notes of `version` in a manifest, when that is the entry it lists. +fn notes_for(manifest: &str, version: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(manifest).ok()?; + if value.get("version")?.as_str()? != version { + return None; + } + let notes = value.get("notes")?.as_str()?.trim(); + (!notes.is_empty()).then(|| notes.to_string()) +} + #[cfg(test)] mod tests { + #[test] + fn release_notes_come_from_the_manifest_of_that_version() { + let manifest = serde_json::json!({ + "version": "0.1.40", + "notes": "## What's new\n\n- dev containers", + "pub_date": "2026-11-01T00:00:00Z", + }) + .to_string(); + assert_eq!(notes_for(&manifest, "0.1.40").as_deref(), Some("## What's new\n\n- dev containers")); + // Another version, empty notes, or not JSON: nothing to show. + assert_eq!(notes_for(&manifest, "0.1.41"), None); + assert_eq!(notes_for(r#"{ "version": "0.1.40", "notes": " " }"#, "0.1.40"), None); + assert_eq!(notes_for(r#"{ "version": "0.1.40" }"#, "0.1.40"), None); + assert_eq!(notes_for("not json", "0.1.40"), None); + } + #[test] fn menu_configuration_follows_the_active_window_or_its_settings_parent() { for (source, target, allowed) in [ diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index bf026749c..c242123f4 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -865,6 +865,7 @@ pub fn spec(os: Os) -> Vec { act("navigate.findAction", "Find Action…"), act("help.keymapReference", "Keymap Reference"), SEP, + act("app.whatsNew", "What's New"), act("help.showLog", show_log), ]; if !mac { diff --git a/src/bindings.ts b/src/bindings.ts index c55fe002f..2df37baa8 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1087,6 +1087,13 @@ export const commands = { * with `app_restart`. One install runs at a time. */ appInstallUpdate: (source: UpdateSource, request: RequestId, onProgress: Channel) => typedError(__TAURI_INVOKE("app_install_update", { source, request, onProgress })), + /** + * Release notes of `version` from the update manifest (`latest.json`), read + * through the configured source. An install records the notes it saw, so this + * is only for an update installed outside the app. `Ok(None)` when the manifest + * covers another version or carries no notes. + */ + appReleaseNotes: (version: string, source: UpdateSource) => typedError(__TAURI_INVOKE("app_release_notes", { version, source })), debugExceptionFilters: () => typedError(__TAURI_INVOKE("debug_exception_filters")), debugSetExceptionBreakpoints: (choices: { [key in string]: boolean }) => typedError(__TAURI_INVOKE("debug_set_exception_breakpoints", { choices })), debugSetVariable: (session: DebugSessionId, container: number, name: string, evaluateName: string | null, frameId: number | null, value: string) => typedError(__TAURI_INVOKE("debug_set_variable", { session, container, name, evaluateName, frameId, value })), @@ -4686,6 +4693,10 @@ export type GeneralSettings = { /** Where updates come from: `auto` (the CNB mirror for the Chinese UI, else GitHub) | `github` | `cnb`. */ updateSource?: string, autoCheckUpdates?: boolean, + /** System Settings › Updates: open the What's New page after the app starts a new version. */ + showWhatsNewAfterUpdate?: boolean, + /** The version the app last started with, so the first start after an update is recognisable. */ + lastRunVersion?: string, }; /** A repository of the signed-in account (owned, collaborator, or organization member). */ diff --git a/src/features/app/__tests__/updates.test.ts b/src/features/app/__tests__/updates.test.ts index fd24690f6..15ebb20dc 100644 --- a/src/features/app/__tests__/updates.test.ts +++ b/src/features/app/__tests__/updates.test.ts @@ -34,6 +34,9 @@ const appRestart = vi.fn(async () => null); const cancelRequest = vi.fn(async () => null); const appBuildInfo = vi.fn(async () => ({ version: "0.1.21" })); const settingsSet = vi.fn(async () => ({ status: "ok", data: { general: {}, appearance: {} } })); +const recordInstalledUpdate = vi.fn(); + +vi.mock("@/platform/releaseNotes", () => ({ recordInstalledUpdate })); vi.mock("@/platform/ipc", async (orig) => { const actual = await orig(); @@ -65,12 +68,12 @@ const { installUpdate, notifyUpdate, progressText, - updateSource, CHECK_TASK_ID, INSTALL_TASK_ID, UPDATE_KEY, } = await import("../updates"); const { provideNotificationService } = await import("@/workbench/contributions"); +const { updateSource } = await import("@/platform/updateSource"); const { useProject } = await import("@/platform/project"); const { useTasks } = await import("@/workbench/tasks"); @@ -118,12 +121,32 @@ describe("Install and Restart", () => { await done; expect(task(INSTALL_TASK_ID)?.done).toBe(true); expect(appRestart).toHaveBeenCalledTimes(1); + expect(recordInstalledUpdate).toHaveBeenCalledWith("0.1.22", null); const installed = notes.at(-1)!; expect(installed).toMatchObject({ title: "OpenIDEA 0.1.22 is installed", key: UPDATE_KEY, sticky: true }); installed.actions![0]!.run(); expect(appRestart).toHaveBeenCalledTimes(2); }); + it("puts only the first paragraph in the balloon, the page keeps the whole notes", async () => { + const longNotes = "A short summary.\n\n## What's new\n\n- " + "detail ".repeat(80); + await notifyUpdate({ version: "0.1.22", currentVersion: "0.1.21", notes: longNotes, date: null }); + expect(notes.at(-1)?.body).toBe("A short summary."); + + notes.at(-1)!.actions![0]!.run(); + install.resolve(ok(null)); + await tick(); + expect(recordInstalledUpdate).toHaveBeenCalledWith("0.1.22", longNotes); + }); + + it("remembers the notes of the update it installed, for the page after the restart", async () => { + await notifyUpdate({ version: "0.1.22", currentVersion: "0.1.21", notes: "## Fixed\n- a crash", date: null }); + notes[0]!.actions![0]!.run(); + install.resolve(ok(null)); + await tick(); + expect(recordInstalledUpdate).toHaveBeenCalledWith("0.1.22", "## Fixed\n- a crash"); + }); + it("joins the running install on repeated clicks (balloon, log entry, a second check)", async () => { await notifyUpdate({ version: "0.1.22", currentVersion: "0.1.21", notes: null, date: null }); const action = notes[0]!.actions![0]!; @@ -151,6 +174,7 @@ describe("Install and Restart", () => { install.resolve({ status: "error", error: { kind: "unavailable", message: "connection reset" } }); await failed; expect(appRestart).not.toHaveBeenCalled(); + expect(recordInstalledUpdate).not.toHaveBeenCalled(); expect(task(INSTALL_TASK_ID)?.done).toBe(true); expect(notes.at(-1)).toMatchObject({ level: "error", diff --git a/src/features/app/updates.tsx b/src/features/app/updates.tsx index ea0af9ec1..1d42dbfd6 100644 --- a/src/features/app/updates.tsx +++ b/src/features/app/updates.tsx @@ -7,7 +7,7 @@ * repeated click joins the running check or install instead of starting another. */ import { Channel } from "@tauri-apps/api/core"; -import { resolveLocale, t } from "@/platform/i18n"; +import { t } from "@/platform/i18n"; import { call, commands, @@ -21,6 +21,8 @@ import { type UpdateSource, } from "@/platform/ipc"; import { useProject } from "@/platform/project"; +import { recordInstalledUpdate } from "@/platform/releaseNotes"; +import { updateSource } from "@/platform/updateSource"; import { notifications } from "@/workbench/contributions"; import { upsertTask } from "@/workbench/tasks"; import { UpdateBalloonBody } from "./UpdateBalloonBody"; @@ -33,14 +35,6 @@ function errorText(e: unknown) { return e instanceof Error ? e.message : String(e); } -/** System Settings › Updates › Update source; `auto` picks the CNB mirror for the Chinese UI. */ -export function updateSource(): UpdateSource { - const { settings } = useProject.getState(); - const chosen = settings?.general?.updateSource; - if (chosen === "github" || chosen === "cnb") return chosen; - return resolveLocale(settings?.appearance?.locale) === "zh" ? "cnb" : "github"; -} - const SOURCE_NAMES: Record = { github: "GitHub", cnb: "CNB" }; /** Makes `source` the update source; the retry it precedes uses it even if saving fails. */ @@ -58,11 +52,11 @@ async function chooseSource(source: UpdateSource) { * Offered once a download failed or was cancelled (likely too slow): the same install from * the other source. A check never needs it, since it already falls back to the other source. */ -function otherSourceAction(version: string, source: UpdateSource) { +function otherSourceAction(version: string, source: UpdateSource, notes: string | null) { const other: UpdateSource = source === "cnb" ? "github" : "cnb"; return { label: t("app.update.retryWith", { source: SOURCE_NAMES[other] }), - run: () => void chooseSource(other).then(() => installUpdate(version, other)), + run: () => void chooseSource(other).then(() => installUpdate(version, other, notes)), }; } @@ -157,6 +151,14 @@ export function installUpdateChecks(): () => void { }; } +/** The balloon shows the first paragraph; the whole notes belong to the What's New page. */ +function updateSummary(notes: string | null): string | undefined { + const text = notes?.trim(); + if (!text) return undefined; + const paragraph = text.split(/\r?\n\s*\r?\n/)[0]?.trim() || text; + return paragraph.length > 300 ? paragraph.slice(0, 300).trimEnd() + "…" : paragraph; +} + /** `userInitiated`: the update came from a manual check, so it pops up regardless of the balloon settings. */ export async function notifyUpdate(update: UpdateInfo, userInitiated = false): Promise { if (isTauri()) { @@ -169,10 +171,12 @@ export async function notifyUpdate(update: UpdateInfo, userInitiated = false): P notifications().notify({ level: "info", title: t("app.update.available", { version: update.version }), - body: update.notes ?? undefined, + body: updateSummary(update.notes ?? null), sticky: true, key: UPDATE_KEY, - actions: [{ label: t("app.update.install"), run: () => void installUpdate(update.version) }], + actions: [ + { label: t("app.update.install"), run: () => void installUpdate(update.version, updateSource(), update.notes) }, + ], userInitiated, }); } @@ -195,13 +199,13 @@ export function progressText(p: UpdateProgress): { message: string; fraction: nu } /** Downloads and installs `version`, then restarts. A second call while one runs joins it. */ -export function installUpdate(version: string, source = updateSource()): Promise { +export function installUpdate(version: string, source = updateSource(), notes: string | null = null): Promise { if (!isTauri()) return Promise.resolve(); - installing ??= runInstall(version, source).finally(() => (installing = null)); + installing ??= runInstall(version, source, notes).finally(() => (installing = null)); return installing; } -async function runInstall(version: string, source: UpdateSource) { +async function runInstall(version: string, source: UpdateSource, notes: string | null) { const title = t("app.update.downloading", { version }); const request = nextRequestId(); const cancel = () => void commands.cancelRequest(request).catch(() => {}); @@ -245,7 +249,7 @@ async function runInstall(version: string, source: UpdateSource) { level: "info", title: t("app.update.cancelled"), key: UPDATE_KEY, - actions: [otherSourceAction(version, source)], + actions: [otherSourceAction(version, source, notes)], userInitiated: true, }); } else { @@ -255,8 +259,8 @@ async function runInstall(version: string, source: UpdateSource) { body: errorText(e), key: UPDATE_KEY, actions: [ - { label: t("app.update.retry"), run: () => void installUpdate(version, source) }, - otherSourceAction(version, source), + { label: t("app.update.retry"), run: () => void installUpdate(version, source, notes) }, + otherSourceAction(version, source, notes), ], userInitiated: true, }); @@ -265,6 +269,8 @@ async function runInstall(version: string, source: UpdateSource) { } finally { upsertTask({ id: INSTALL_TASK_ID, title, message: null, fraction: 1, done: true }); } + // The restarted app reads this to show the What's New page of the version it now runs. + recordInstalledUpdate(version, notes); const restart = () => void commands.appRestart(); // Stays if a window cancels the restart (unsaved changes): the update applies on the next start. notifications().notify({ diff --git a/src/features/index.ts b/src/features/index.ts index 6ce8624d9..9e8226a6d 100644 --- a/src/features/index.ts +++ b/src/features/index.ts @@ -54,6 +54,7 @@ import { activate as agents, activateSettingsPages as agentsPages } from "./agen import { activate as vim, activateSettingsPages as vimPages } from "./vim"; import { activate as ssh } from "./ssh"; import { activate as remoteDev } from "./remote-dev"; +import { activate as whatsNew } from "./whats-new"; type Activate = () => void | (() => void); @@ -63,6 +64,7 @@ const features: Array<[string, Activate]> = [ ["editor-lsp", editorLsp], ["mcp", mcp], ["app", app], + ["whats-new", whatsNew], ["welcome", welcome], ["project-tree", projectTree], ["search", search], diff --git a/src/features/settings/model/behaviorSchema.ts b/src/features/settings/model/behaviorSchema.ts index acb05fc43..d202229e3 100644 --- a/src/features/settings/model/behaviorSchema.ts +++ b/src/features/settings/model/behaviorSchema.ts @@ -69,6 +69,10 @@ export interface GeneralExtras { openProjectIn: OpenProjectIn; updateSource: UpdateSourceSetting; autoCheckUpdates: boolean; + /** System Settings › Updates: show the What's New page after the app starts a new version. */ + showWhatsNewAfterUpdate: boolean; + /** Version the app last started with; empty on the first run. */ + lastRunVersion: string; defaultProjectDirectory: string; saveOnIdle: boolean; idleSeconds: number; @@ -127,6 +131,8 @@ export const DEFAULT_GENERAL_EXTRAS: GeneralExtras = { openProjectIn: "ask", updateSource: "auto", autoCheckUpdates: true, + showWhatsNewAfterUpdate: true, + lastRunVersion: "", defaultProjectDirectory: "", saveOnIdle: false, idleSeconds: 15, @@ -201,6 +207,8 @@ export function validateGeneralExtras(raw: unknown): GeneralExtras { openProjectIn: oneOf(g.openProjectIn, ["ask", "new", "same"] as const, d.openProjectIn), updateSource: oneOf(g.updateSource, ["auto", "github", "cnb"] as const, d.updateSource), autoCheckUpdates: bool(g.autoCheckUpdates, d.autoCheckUpdates), + showWhatsNewAfterUpdate: bool(g.showWhatsNewAfterUpdate, d.showWhatsNewAfterUpdate), + lastRunVersion: typeof g.lastRunVersion === "string" ? g.lastRunVersion : d.lastRunVersion, defaultProjectDirectory: typeof g.defaultProjectDirectory === "string" ? g.defaultProjectDirectory : "", saveOnIdle: bool(g.saveOnIdle, d.saveOnIdle), idleSeconds: int(g.idleSeconds, d.idleSeconds, IDLE_LIMITS), diff --git a/src/features/settings/pages/updates.tsx b/src/features/settings/pages/updates.tsx index 818f996a2..3e3eaa1b6 100644 --- a/src/features/settings/pages/updates.tsx +++ b/src/features/settings/pages/updates.tsx @@ -70,6 +70,12 @@ export const UPDATES_PAGE: PageSpec = { label: "Check for updates automatically", hint: "At startup and every 4 hours while OpenIDEA runs. Each new version is announced once per session.", }, + { + kind: "toggle", + key: "general.showWhatsNewAfterUpdate", + label: "Show What's New after an update", + hint: "Opens a page with the release notes when a new version starts. Help › What's New opens it any time.", + }, { kind: "select", key: "general.updateSource", diff --git a/src/features/whats-new/WhatsNew.module.css b/src/features/whats-new/WhatsNew.module.css new file mode 100644 index 000000000..ef4f22a3a --- /dev/null +++ b/src/features/whats-new/WhatsNew.module.css @@ -0,0 +1,189 @@ +/* What's New: one reading column on the page, like a document tab. */ +.page { + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; + background: var(--bg-editor); + color: var(--fg); +} + +/* The title, the notes and the footer share this column. */ +.column { + width: 100%; + max-width: 820px; + margin: 0 auto; + padding: 0 32px; + box-sizing: border-box; +} + +.header { + flex: none; + padding: 22px 0 14px; + border-bottom: 1px solid var(--border); +} + +.title { + margin: 0; + color: var(--fg-strong); + font-size: 20px; + font-weight: 600; + line-height: 1.3; +} + +.version { + margin-top: 3px; + color: var(--fg-muted); + font-size: var(--fs-small); +} + +.body { + flex: 1; + min-height: 0; + overflow: auto; + padding: 20px 0 32px; +} + +/* Fills the body so an empty or loading state can sit in its middle. */ +.bodyColumn { + flex: 1; + display: flex; + flex-direction: column; +} + +.notes { + font-size: var(--fs-ui); + line-height: 1.65; + overflow-wrap: anywhere; +} + +.notes > :first-child { + margin-top: 0; +} + +.notes :where(h1, h2, h3) { + margin: 22px 0 10px; + color: var(--fg-strong); + font-weight: 600; +} + +.notes :where(h1) { + font-size: 18px; +} + +.notes :where(h2) { + font-size: var(--fs-input-large); +} + +.notes :where(h3) { + font-size: var(--fs-ui); +} + +.notes :where(p) { + margin: 0 0 12px; +} + +.notes :where(ul, ol) { + margin: 0 0 12px; + padding-left: 22px; +} + +.notes :where(li) { + margin: 3px 0; +} + +.notes :where(a) { + color: var(--link); + text-decoration: none; +} + +.notes :where(a:hover) { + color: var(--link-hover); + text-decoration: underline; +} + +.notes :where(code) { + padding: 1px 4px; + border-radius: var(--radius-small); + background: var(--bg-input); + font-family: var(--font-mono); + font-size: var(--fs-code); +} + +.notes :where(pre) { + overflow: auto; + margin: 0 0 14px; + padding: 10px 12px; + border-radius: var(--radius-small); + background: var(--bg-input); +} + +.notes :where(pre code) { + padding: 0; + background: none; +} + +.notes :where(blockquote) { + margin: 0 0 12px; + padding-left: 12px; + border-left: 2px solid var(--border-strong); + color: var(--fg-secondary); +} + +.notes :where(hr) { + height: 1px; + margin: 18px 0; + border: 0; + background: var(--border); +} + +.notes :where(img) { + max-width: 100%; +} + +.notes :where(table) { + margin: 0 0 14px; + border-collapse: collapse; +} + +.notes :where(th, td) { + padding: 4px 8px; + border: 1px solid var(--border); + text-align: left; +} + +.empty { + margin: auto; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + max-width: 480px; + color: var(--fg-secondary); + text-align: center; +} + +.link { + padding: 0; + border: 0; + background: none; + color: var(--link); + font: inherit; + cursor: pointer; +} + +.link:hover { + color: var(--link-hover); + text-decoration: underline; +} + +.footer { + flex: none; + padding: 10px 0; + border-top: 1px solid var(--border); +} + +.footerRow { + display: flex; + align-items: center; +} diff --git a/src/features/whats-new/WhatsNew.tsx b/src/features/whats-new/WhatsNew.tsx new file mode 100644 index 000000000..65fbd3352 --- /dev/null +++ b/src/features/whats-new/WhatsNew.tsx @@ -0,0 +1,97 @@ +/** + * What's New: an editor tab with the release notes of one version. The install + * records the notes; a version installed outside the app reads the manifest. + */ +import { useEffect, useState } from "react"; +import { useMarkupHtml } from "@/editor/lsp/markdown"; +import { useT } from "@/platform/i18n"; +import { call, commands } from "@/platform/ipc"; +import { openExternalUrl } from "@/platform/openUrl"; +import { useProject } from "@/platform/project"; +import { updateSource } from "@/platform/updateSource"; +import { Checkbox, selectableText } from "@/ui"; +import type { EditorInput } from "@/workbench/contributions"; +import { setShowAfterUpdate, type WhatsNewData } from "./model"; +import styles from "./WhatsNew.module.css"; + +const RELEASES = "https://github.com/l0ng-ai/openidea/releases"; + +function releasePage(version: string) { + return version ? RELEASES + "/tag/v" + version : RELEASES; +} + +export function WhatsNewPage({ input }: { input: EditorInput }) { + const t = useT(); + const data = input.data as Partial | undefined; + const version = data?.version ?? ""; + const [notes, setNotes] = useState(data?.notes ?? null); + // Nothing recorded and a version to look up: the manifest read starts with the first render. + const [loading, setLoading] = useState(!data?.notes && !!version); + const show = useProject((s) => s.settings?.general?.showWhatsNewAfterUpdate !== false); + // Installed outside the app: no notes were recorded, read the update manifest. + useEffect(() => { + if (notes || !version) return; + let live = true; + void call(commands.appReleaseNotes(version, updateSource())) + .catch(() => null) + .then((fetched) => { + if (!live) return; + setNotes(fetched); + setLoading(false); + }); + return () => { + live = false; + }; + }, [notes, version]); + const html = useMarkupHtml({ kind: "markdown", value: notes ?? "" }); + return ( +
+
+
+

{t("app.whatsNew.title")}

+
{t("app.whatsNew.version", { version })}
+
+
+
+
+ {notes?.trim() ? ( +
{ + const link = (e.target as HTMLElement).closest("a"); + if (!link) return; + // Links in the notes belong in the browser, not in the IDE's window. + e.preventDefault(); + const href = link.getAttribute("href") ?? ""; + if (/^https?:/i.test(href)) void openExternalUrl(href).catch(() => {}); + }} + dangerouslySetInnerHTML={{ __html: html }} + /> + ) : loading ? ( +
{t("app.whatsNew.loading")}
+ ) : ( +
+
{t("app.whatsNew.noNotes")}
+ +
+ )} +
+
+
+
+ void setShowAfterUpdate(checked).catch(() => {})} + label={t("app.whatsNew.showAfterUpdate")} + /> +
+
+
+ ); +} diff --git a/src/features/whats-new/__tests__/whatsNew.test.tsx b/src/features/whats-new/__tests__/whatsNew.test.tsx new file mode 100644 index 000000000..82791bdd4 --- /dev/null +++ b/src/features/whats-new/__tests__/whatsNew.test.tsx @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { Settings } from "@/platform/ipc"; + +const appBuildInfo = vi.fn(async () => ({ version: "0.1.41", updates: true })); +const appReleaseNotes = vi.fn(async (): Promise => ({ status: "ok", data: null })); +// The backend answers with the whole settings object, the changed key included. +const settingsSet = vi.fn(async (_scope: string, key: string, value: string) => { + const current = (useProject.getState().settings ?? { general: {} }) as Settings; + const field = key.slice(key.indexOf(".") + 1); + return { + status: "ok", + data: { ...current, general: { ...current.general, [field]: JSON.parse(value) } }, + }; +}); +const settingsGet = vi.fn(async (): Promise => ({ general: { lastRunVersion: "" } })); + +vi.mock("@/platform/ipc", async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + isTauri: () => true, + commands: { ...real.commands, appBuildInfo, appReleaseNotes, settingsSet, settingsGet }, + }; +}); + +// The Markdown pipeline has its own tests; the page only has to render what it returns. +vi.mock("@/editor/lsp/markdown", () => ({ + useMarkupHtml: ({ value }: { value: string }) => "

" + value + "

", +})); + +const { useProject } = await import("@/platform/project"); +const { _resetReleaseNotesForTests, recordInstalledUpdate } = await import("@/platform/releaseNotes"); +const { provideEditorService } = await import("@/workbench/contributions"); +const { checkAfterUpdate, openCurrentWhatsNew } = await import("../model"); +const { WhatsNewPage } = await import("../WhatsNew"); + +const openInput = vi.fn(async (_input: unknown, _options?: unknown) => {}); +provideEditorService({ + openInput, + openFile: vi.fn(), + activePath: () => null, + activeCursor: () => null, + saveAll: vi.fn(), +} as never); + +function settingsWith(extra: Record): Settings { + return { general: { showWhatsNewAfterUpdate: true, lastRunVersion: "0.1.40", ...extra } } as unknown as Settings; +} + +beforeEach(() => { + _resetReleaseNotesForTests(); + localStorage.clear(); + openInput.mockClear(); + settingsSet.mockClear(); + useProject.setState({ settings: settingsWith({}) }); +}); + +describe("the first start after an update", () => { + it("records the version and opens the page once", async () => { + recordInstalledUpdate("0.1.41", "## What's new\n\n- dev containers"); + await checkAfterUpdate(); + expect(settingsSet).toHaveBeenCalledWith("global", "general.lastRunVersion", JSON.stringify("0.1.41")); + expect(openInput).toHaveBeenCalledTimes(1); + expect(openInput.mock.calls[0]![0]).toMatchObject({ + kind: "whatsNew", + key: "whatsNew:0.1.41", + data: { version: "0.1.41", notes: "## What's new\n\n- dev containers" }, + }); + }); + + it("does nothing on a start of the version it already ran", async () => { + useProject.setState({ settings: settingsWith({ lastRunVersion: "0.1.41" }) }); + await checkAfterUpdate(); + expect(settingsSet).not.toHaveBeenCalled(); + expect(openInput).not.toHaveBeenCalled(); + }); + + it("records the first run without opening anything", async () => { + useProject.setState({ settings: settingsWith({ lastRunVersion: "" }) }); + await checkAfterUpdate(); + expect(settingsSet).toHaveBeenCalledWith("global", "general.lastRunVersion", JSON.stringify("0.1.41")); + expect(openInput).not.toHaveBeenCalled(); + }); + + it("records the start but stays closed when the setting is off", async () => { + useProject.setState({ settings: settingsWith({ showWhatsNewAfterUpdate: false }) }); + await checkAfterUpdate(); + expect(settingsSet).toHaveBeenCalledWith("global", "general.lastRunVersion", JSON.stringify("0.1.41")); + expect(openInput).not.toHaveBeenCalled(); + }); + + it("does nothing in a build without an update endpoint", async () => { + appBuildInfo.mockResolvedValueOnce({ version: "0.1.41", updates: false }); + await checkAfterUpdate(); + expect(settingsSet).not.toHaveBeenCalled(); + expect(openInput).not.toHaveBeenCalled(); + }); + + it("opens the running version from Help at any time", async () => { + await openCurrentWhatsNew(); + expect(openInput).toHaveBeenCalledWith(expect.objectContaining({ kind: "whatsNew", key: "whatsNew:0.1.41" }), { + focus: true, + }); + }); +}); + +describe("the page", () => { + const input = (notes: string | null) => ({ + kind: "whatsNew", + key: "whatsNew:0.1.41", + data: { version: "0.1.41", notes }, + }); + + it("shows the version and the notes, with its checkbox on by default", () => { + render(); + expect(screen.getByText("OpenIDEA 0.1.41")).toBeInTheDocument(); + expect(document.body.textContent).toContain("Fixed a crash"); + expect(screen.getByRole("checkbox")).toBeChecked(); + }); + + it("writes the checkbox to the setting, which the page reads back", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("checkbox")); + expect(settingsSet).toHaveBeenCalledWith("global", "general.showWhatsNewAfterUpdate", "false"); + expect(useProject.getState().settings?.general?.showWhatsNewAfterUpdate).toBe(false); + expect(screen.getByRole("checkbox")).not.toBeChecked(); + }); + + it("reads the manifest when the install recorded no notes", async () => { + appReleaseNotes.mockResolvedValueOnce({ status: "ok", data: "## From the manifest" }); + render(); + await waitFor(() => expect(document.body.textContent).toContain("From the manifest")); + expect(appReleaseNotes).toHaveBeenCalledWith("0.1.41", "github"); + }); + + it("points at the release page when there are no notes to show", async () => { + render(); + await waitFor(() => expect(screen.getByText("There are no release notes for this version.")).toBeInTheDocument()); + expect(screen.getByRole("button", { name: "Open Release Page" })).toBeInTheDocument(); + }); +}); diff --git a/src/features/whats-new/index.ts b/src/features/whats-new/index.ts new file mode 100644 index 000000000..4644eb155 --- /dev/null +++ b/src/features/whats-new/index.ts @@ -0,0 +1,35 @@ +/** The What's New tab: opened from Help, and once on the first start after an update. */ +import { t } from "@/platform/i18n"; +import { registerActions } from "@/platform/actions"; +import { registerEditorKind } from "@/workbench/contributions"; +import { checkAfterUpdate, openCurrentWhatsNew, WHATS_NEW_KIND } from "./model"; +import { WhatsNewPage } from "./WhatsNew"; + +export function activate(): () => void { + const disposers = [ + registerEditorKind({ + kind: WHATS_NEW_KIND, + component: WhatsNewPage, + title: () => t("app.whatsNew.title"), + icon: () => "info", + // A page to read once: not saved with the session, shown without a project too. + restorable: false, + standalone: true, + }), + registerActions([ + { + id: "app.whatsNew", + title: "What's New", + category: "Help", + icon: "info", + keywords: ["release notes", "changelog", "update notes"], + run: () => openCurrentWhatsNew(), + }, + ]), + ]; + // The first start of a different version shows what changed. The update checks + // themselves start from the app feature, where a launch project may still be + // opening; running here means a project-open context never swallows the check. + void checkAfterUpdate(); + return () => disposers.reverse().forEach((d) => d()); +} diff --git a/src/features/whats-new/model.ts b/src/features/whats-new/model.ts new file mode 100644 index 000000000..ea750c214 --- /dev/null +++ b/src/features/whats-new/model.ts @@ -0,0 +1,91 @@ +/** + * The What's New page: an editor tab with the release notes of one version. + * Help opens it at any time; checkAfterUpdate opens it once on the first start + * after an update (called from features/app once the window is up). + */ +import { call, commands, isTauri } from "@/platform/ipc"; +import { loadSettings, startupLaunchHandled, useProject } from "@/platform/project"; +import { releaseNotesOf } from "@/platform/releaseNotes"; +import { maybeEditorService, type EditorInput } from "@/workbench/contributions"; + +export const WHATS_NEW_KIND = "whatsNew"; + +export interface WhatsNewData { + version: string; + /** Notes the install recorded; null when the page has to read the manifest. */ + notes: string | null; +} + +export function whatsNewInput(version: string): EditorInput { + return { + kind: WHATS_NEW_KIND, + key: WHATS_NEW_KIND + ":" + version, + data: { version, notes: releaseNotesOf(version) } satisfies WhatsNewData, + }; +} + +/** Opens the tab of this version; the same version opened again focuses it. */ +export function openWhatsNew(version: string): void { + void maybeEditorService()?.openInput(whatsNewInput(version), { focus: true }); +} + +/** Help > What's New: the version the app runs. */ +export async function openCurrentWhatsNew(): Promise { + const info = await commands.appBuildInfo().catch(() => null); + if (!info) return; + openWhatsNew(info.version); +} + +/** general.showWhatsNewAfterUpdate, on unless it was switched off in Settings. */ +export function showAfterUpdate(): boolean { + return useProject.getState().settings?.general?.showWhatsNewAfterUpdate !== false; +} + +/** The page's checkbox: Settings > Updates shows the same switch. */ +export async function setShowAfterUpdate(show: boolean): Promise { + const settings = await call(commands.settingsSet("global", "general.showWhatsNewAfterUpdate", JSON.stringify(show))); + useProject.setState({ settings }); +} + +/** The splash covers the window, and the remembered project restores right after it. */ +async function windowReady(): Promise { + if (typeof document !== "undefined" && document.getElementById("splash")) { + await new Promise((resolve) => { + const done = () => { + window.removeEventListener("openidea:boot-ready", done); + resolve(); + }; + window.addEventListener("openidea:boot-ready", done); + // The splash gives up after 4 s; a failed boot must not swallow the page. + setTimeout(done, 5000); + }); + } + await startupLaunchHandled(); + // A remembered project reopens after the splash and replaces the saved tab session, + // so give it the start: opening while it restores would close the page again. + await new Promise((resolve) => setTimeout(resolve, 300)); +} + +async function rememberRunVersion(version: string): Promise { + const settings = await call(commands.settingsSet("global", "general.lastRunVersion", JSON.stringify(version))); + useProject.setState({ settings }); +} + +/** First start of a different version opens the page; one start opens it once. */ +export async function checkAfterUpdate(): Promise { + if (!isTauri()) return; + const info = await commands.appBuildInfo().catch(() => null); + // A build without an update endpoint never installed a version: nothing to show. + if (!info?.updates) return; + // The Welcome screen has no settings loaded yet. + if (!useProject.getState().settings) await loadSettings().catch(() => {}); + const previous = useProject.getState().settings?.general?.lastRunVersion ?? ""; + if (previous === info.version) return; + // Record the start before opening: an update installed outside the app is + // recognised the same way as one installed by the updater. + await rememberRunVersion(info.version); + // The first run has nothing to compare against. + if (!previous || !showAfterUpdate()) return; + await windowReady(); + openWhatsNew(info.version); +} diff --git a/src/platform/clientSettings.ts b/src/platform/clientSettings.ts index 96acb44d0..410e25141 100644 --- a/src/platform/clientSettings.ts +++ b/src/platform/clientSettings.ts @@ -15,6 +15,8 @@ export const CLIENT_SETTINGS_KEYS = [ "general.openProjectIn", "general.updateSource", "general.autoCheckUpdates", + "general.showWhatsNewAfterUpdate", + "general.lastRunVersion", "general.dateFormat", "editor.fontFamily", "editor.fontSize", diff --git a/src/platform/locales/en.json b/src/platform/locales/en.json index 8bdf026c6..4ecd627ce 100644 --- a/src/platform/locales/en.json +++ b/src/platform/locales/en.json @@ -83,6 +83,12 @@ "app.update.restart": "Restart", "app.update.retry": "Retry", "app.update.retryWith": "Retry with {source}", + "app.whatsNew.loading": "Fetching release notes…", + "app.whatsNew.noNotes": "There are no release notes for this version.", + "app.whatsNew.openReleases": "Open Release Page", + "app.whatsNew.showAfterUpdate": "Show this page after every update", + "app.whatsNew.title": "What's New", + "app.whatsNew.version": "OpenIDEA {version}", "app.window.desktopOnly": "Multiple windows require the desktop app", "app.window.openFailed": "Cannot open {name}", "app.window.pickFileTitle": "Open File", @@ -109,6 +115,7 @@ "menu.app.installCli": "Install 'openidea' Command", "menu.app.quit": "{os, select, macos {Quit OpenIDEA} other {Exit}}", "menu.app.uninstallCli": "Uninstall 'openidea' Command", + "menu.app.whatsNew": "What's New", "menu.appearance": "Appearance", "menu.code": "Code", "menu.code.commentBlock": "Comment with Block Comment", diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 8e2bb6ae8..b5a1bd132 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -989,5 +989,7 @@ "Imported {count} MCP servers from the project.": "Imported {count} MCP servers from the project.", "Show branch and incoming/outgoing commits in the Project view": "Show branch and incoming/outgoing commits in the Project view", "Up to date with {upstream}": "Up to date with {upstream}", - "Highlight occurrences of selected text": "Highlight occurrences of selected text" + "Highlight occurrences of selected text": "Highlight occurrences of selected text", + "Show What's New after an update": "Show What's New after an update", + "Opens a page with the release notes when a new version starts. Help › What's New opens it any time.": "Opens a page with the release notes when a new version starts. Help › What's New opens it any time." } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index d6921aceb..e7d7fbaff 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -7325,5 +7325,7 @@ "Imported {count} MCP servers from the project.": "已从项目导入 {count} 个 MCP 服务器。", "Show branch and incoming/outgoing commits in the Project view": "在项目视图中显示分支及传入/传出提交", "Up to date with {upstream}": "已与 {upstream} 同步", - "Highlight occurrences of selected text": "高亮所选文本的匹配项" + "Highlight occurrences of selected text": "高亮所选文本的匹配项", + "Show What's New after an update": "更新后显示“新功能”页面", + "Opens a page with the release notes when a new version starts. Help › What's New opens it any time.": "新版本首次启动时显示包含发行说明的页面。可随时通过“帮助 › 新功能”打开。" } diff --git a/src/platform/locales/zh.json b/src/platform/locales/zh.json index 7c9af1bc6..6311022b9 100644 --- a/src/platform/locales/zh.json +++ b/src/platform/locales/zh.json @@ -83,6 +83,12 @@ "app.update.restart": "重启", "app.update.retry": "重试", "app.update.retryWith": "改用 {source} 重试", + "app.whatsNew.loading": "正在获取发行说明…", + "app.whatsNew.noNotes": "此版本没有发行说明。", + "app.whatsNew.openReleases": "打开发布页面", + "app.whatsNew.showAfterUpdate": "每次更新后显示此页面", + "app.whatsNew.title": "新功能", + "app.whatsNew.version": "OpenIDEA {version}", "app.window.desktopOnly": "多窗口需要桌面应用", "app.window.openFailed": "无法打开 {name}", "app.window.pickFileTitle": "打开文件", @@ -109,6 +115,7 @@ "menu.app.installCli": "安装 'openidea' 命令", "menu.app.quit": "{os, select, macos {退出 OpenIDEA} other {退出}}", "menu.app.uninstallCli": "卸载 'openidea' 命令", + "menu.app.whatsNew": "新功能", "menu.appearance": "外观", "menu.code": "代码", "menu.code.commentBlock": "使用块注释", diff --git a/src/platform/releaseNotes.test.ts b/src/platform/releaseNotes.test.ts new file mode 100644 index 000000000..94a081099 --- /dev/null +++ b/src/platform/releaseNotes.test.ts @@ -0,0 +1,33 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { _resetReleaseNotesForTests, recordInstalledUpdate, releaseNotesOf } from "./releaseNotes"; + +beforeEach(() => { + _resetReleaseNotesForTests(); +}); + +describe("the notes an install records", () => { + it("are shown for the version that was installed", () => { + recordInstalledUpdate("0.1.40", "## What's new\n\n- dev containers"); + expect(releaseNotesOf("0.1.40")).toBe("## What's new\n\n- dev containers"); + expect(releaseNotesOf("0.1.41")).toBeNull(); + }); + + it("replace the previous install's notes", () => { + recordInstalledUpdate("0.1.40", "- first"); + recordInstalledUpdate("0.1.41", "- second"); + expect(releaseNotesOf("0.1.40")).toBeNull(); + expect(releaseNotesOf("0.1.41")).toBe("- second"); + }); + + it("leave nothing when the manifest had no notes", () => { + recordInstalledUpdate("0.1.40", "- first"); + recordInstalledUpdate("0.1.41", " "); + expect(releaseNotesOf("0.1.40")).toBeNull(); + expect(releaseNotesOf("0.1.41")).toBeNull(); + }); + + it("ignore a stored value of the wrong shape", () => { + localStorage.setItem("openidea.whatsNew.notes.v1", '{"version":5,"text":true}'); + expect(releaseNotesOf("0.1.40")).toBeNull(); + }); +}); diff --git a/src/platform/releaseNotes.ts b/src/platform/releaseNotes.ts new file mode 100644 index 000000000..efc4b8f08 --- /dev/null +++ b/src/platform/releaseNotes.ts @@ -0,0 +1,52 @@ +/** + * The release notes the updater installed, kept so the What's New page can show + * them after the restart that installs an update. localStorage, because an + * update replaces the app bundle but not the webview's data. + */ + +const KEY = "openidea.whatsNew.notes.v1"; + +interface Stored { + version: string; + text: string; +} + +function read(): Stored | null { + try { + const raw = globalThis.localStorage?.getItem(KEY); + const value: unknown = raw ? JSON.parse(raw) : null; + if (!value || typeof value !== "object") return null; + const stored = value as Partial; + return typeof stored.version === "string" && typeof stored.text === "string" + ? { version: stored.version, text: stored.text } + : null; + } catch { + return null; + } +} + +/** Called once an update installed, with the notes its manifest carried. */ +export function recordInstalledUpdate(version: string, notes: string | null): void { + const text = notes?.trim(); + try { + if (text) globalThis.localStorage?.setItem(KEY, JSON.stringify({ version, text })); + else globalThis.localStorage?.removeItem(KEY); + } catch { + /* storage unavailable: the page reads the manifest instead */ + } +} + +/** Notes recorded when this version was installed, so the page shows them offline. */ +export function releaseNotesOf(version: string): string | null { + const stored = read(); + return stored?.version === version ? stored.text : null; +} + +/** Tests only. */ +export function _resetReleaseNotesForTests(): void { + try { + globalThis.localStorage?.removeItem(KEY); + } catch { + /* storage unavailable */ + } +} diff --git a/src/platform/updateSource.ts b/src/platform/updateSource.ts new file mode 100644 index 000000000..b5cb72d35 --- /dev/null +++ b/src/platform/updateSource.ts @@ -0,0 +1,11 @@ +import { resolveLocale } from "@/platform/i18n"; +import type { UpdateSource } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; + +/** System Settings > Updates > Update source; `auto` picks the CNB mirror for the Chinese UI. */ +export function updateSource(): UpdateSource { + const { settings } = useProject.getState(); + const chosen = settings?.general?.updateSource; + if (chosen === "github" || chosen === "cnb") return chosen; + return resolveLocale(settings?.appearance?.locale) === "zh" ? "cnb" : "github"; +}