From 28baa4a57a69f9e2e5ac7c1ced4485ddc65939ee Mon Sep 17 00:00:00 2001
From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Date: Sat, 31 Oct 2026 09:37:46 +0800
Subject: [PATCH] fix(app): show update progress in its notification
The welcome screen has no status bar, so clicking Install and Restart there showed nothing until the download ended. The notification now stays with the download progress and Cancel, and a cancelled update says so.
Fixes #491
---
src/features/app/UpdateBalloonBody.tsx | 14 +++++++++++
src/features/app/__tests__/updates.test.ts | 10 +++++---
src/features/app/{updates.ts => updates.tsx} | 26 +++++++++++++++++---
src/platform/locales/en.json | 2 ++
src/platform/locales/zh.json | 2 ++
5 files changed, 46 insertions(+), 8 deletions(-)
create mode 100644 src/features/app/UpdateBalloonBody.tsx
rename src/features/app/{updates.ts => updates.tsx} (87%)
diff --git a/src/features/app/UpdateBalloonBody.tsx b/src/features/app/UpdateBalloonBody.tsx
new file mode 100644
index 00000000..12210e1f
--- /dev/null
+++ b/src/features/app/UpdateBalloonBody.tsx
@@ -0,0 +1,14 @@
+/** The running update's progress in its balloon, for windows without a status bar (the welcome screen). */
+import { ProgressBar } from "@/ui";
+import { useTasks } from "@/workbench/tasks";
+
+export function UpdateBalloonBody({ task }: { task: string }) {
+ const current = useTasks((s) => s.tasks.find((t) => t.id === task));
+ if (!current || current.done) return null;
+ return (
+ <>
+ {current.message &&
{current.message}
}
+
+ >
+ );
+}
diff --git a/src/features/app/__tests__/updates.test.ts b/src/features/app/__tests__/updates.test.ts
index 483c832c..f1710e97 100644
--- a/src/features/app/__tests__/updates.test.ts
+++ b/src/features/app/__tests__/updates.test.ts
@@ -66,13 +66,15 @@ afterEach(() => vi.clearAllMocks());
describe("Install and Restart", () => {
it("runs as a background task with download progress, then restarts", async () => {
const done = installUpdate("0.1.22");
- expect(expired).toEqual([UPDATE_KEY]);
+ expect(notes.at(-1)).toMatchObject({ title: "Downloading OpenIDEA 0.1.22", key: UPDATE_KEY, sticky: true });
+ expect(notes.at(-1)!.actions![0]!.label).toBe("Cancel");
expect(task(INSTALL_TASK_ID)).toMatchObject({ title: "Downloading OpenIDEA 0.1.22", fraction: null, done: false });
expect(task(INSTALL_TASK_ID)?.cancel).toBeTypeOf("function");
channel!.onmessage({ type: "downloading", downloaded: 21 * 1024 * 1024, total: 84 * 1024 * 1024 });
expect(task(INSTALL_TASK_ID)).toMatchObject({ fraction: 0.25, message: "21.0 MB of 84.0 MB" });
channel!.onmessage({ type: "installing" });
+ expect(notes.at(-1)!.actions).toBeUndefined();
expect(task(INSTALL_TASK_ID)).toMatchObject({ fraction: null, message: "Installing…", cancel: undefined });
install.resolve(ok(null));
@@ -99,7 +101,7 @@ describe("Install and Restart", () => {
ok({ enabled: true, update: { version: "0.1.22", currentVersion: "0.1.21", notes: null, date: null } }),
);
await checked;
- expect(notes).toHaveLength(1);
+ expect(notes.map((n) => n.title)).toEqual(["OpenIDEA 0.1.22 is available", "Downloading OpenIDEA 0.1.22"]);
install.resolve(ok(null));
await tick();
@@ -107,7 +109,7 @@ describe("Install and Restart", () => {
expect(appInstallUpdate).toHaveBeenCalledTimes(2);
});
- it("reports a failure with Retry, and stays quiet when cancelled", async () => {
+ it("reports a failure with Retry, and a cancellation", async () => {
const failed = installUpdate("0.1.22");
install.resolve({ status: "error", error: { kind: "unavailable", message: "connection reset" } });
await failed;
@@ -127,7 +129,7 @@ describe("Install and Restart", () => {
expect(cancelRequest).toHaveBeenCalledWith(appInstallUpdate.mock.calls.at(-1)![0]);
install.resolve({ status: "error", error: { kind: "cancelled", message: "cancelled" } });
await cancelled;
- expect(notes).toEqual([]);
+ expect(notes.at(-1)).toMatchObject({ level: "info", title: "Update cancelled", key: UPDATE_KEY });
expect(appRestart).not.toHaveBeenCalled();
});
});
diff --git a/src/features/app/updates.ts b/src/features/app/updates.tsx
similarity index 87%
rename from src/features/app/updates.ts
rename to src/features/app/updates.tsx
index 5140812c..7cdae992 100644
--- a/src/features/app/updates.ts
+++ b/src/features/app/updates.tsx
@@ -1,6 +1,7 @@
/**
* Update check and install. Both run as background tasks in the status bar: the
- * check while the request is out, the install with download progress and Cancel.
+ * check while the request is out, the install with download progress and Cancel,
+ * which its balloon shows too (the welcome screen has no status bar).
* Every entry point (Help menu, toolbar gear, welcome screen, Search Everywhere,
* the "is available" notification and its log entry) goes through these, so a
* repeated click joins the running check or install instead of starting another.
@@ -18,6 +19,7 @@ import {
} from "@/platform/ipc";
import { notifications } from "@/workbench/contributions";
import { upsertTask } from "@/workbench/tasks";
+import { UpdateBalloonBody } from "./UpdateBalloonBody";
export const UPDATE_KEY = "app.update";
export const CHECK_TASK_ID = "app.update.check";
@@ -117,8 +119,22 @@ async function runInstall(version: string) {
const request = nextRequestId();
const cancel = () => void commands.cancelRequest(request).catch(() => {});
const channel = new Channel();
+ const balloon = (actions?: Array<{ label: string; run: () => void }>) =>
+ notifications().notify({
+ level: "info",
+ title,
+ body: ,
+ sticky: true,
+ key: UPDATE_KEY,
+ actions,
+ });
+ let installingShown = false;
channel.onmessage = (p) => {
const { message, fraction } = progressText(p);
+ if (p.type === "installing" && !installingShown) {
+ installingShown = true;
+ balloon();
+ }
upsertTask({
id: INSTALL_TASK_ID,
title,
@@ -130,13 +146,15 @@ async function runInstall(version: string) {
uncancellable: p.type === "installing" ? t("app.update.installUncancellable") : undefined,
});
};
- // The notification's action is spent: progress lives in the status bar from here.
- notifications().expire?.(UPDATE_KEY);
upsertTask({ id: INSTALL_TASK_ID, title, message: null, fraction: null, done: false, cancel });
+ // Replaces the notification whose action is now spent.
+ balloon([{ label: t("app.update.cancel"), run: cancel }]);
try {
await call(commands.appInstallUpdate(request, channel));
} catch (e) {
- if (!isCancelled(e)) {
+ if (isCancelled(e)) {
+ notifications().notify({ level: "info", title: t("app.update.cancelled"), key: UPDATE_KEY });
+ } else {
notifications().notify({
level: "error",
title: t("app.update.installFailed"),
diff --git a/src/platform/locales/en.json b/src/platform/locales/en.json
index 0904cc10..d63d811c 100644
--- a/src/platform/locales/en.json
+++ b/src/platform/locales/en.json
@@ -56,6 +56,8 @@
"app.unsaved.saveFailed": "Could not save all files",
"app.unsaved.title": "Unsaved Changes",
"app.update.available": "OpenIDEA {version} is available",
+ "app.update.cancel": "Cancel",
+ "app.update.cancelled": "Update cancelled",
"app.update.checking": "Checking for updates…",
"app.update.disabled": "Automatic updates are not configured",
"app.update.disabledBody": "Set an update endpoint (OPENIDEA_UPDATE_ENDPOINT or plugins.updater.endpoints) to enable update checks.",
diff --git a/src/platform/locales/zh.json b/src/platform/locales/zh.json
index 37822f02..79f8adec 100644
--- a/src/platform/locales/zh.json
+++ b/src/platform/locales/zh.json
@@ -56,6 +56,8 @@
"app.unsaved.saveFailed": "无法保存所有文件",
"app.unsaved.title": "未保存的更改",
"app.update.available": "OpenIDEA {version} 可用",
+ "app.update.cancel": "取消",
+ "app.update.cancelled": "已取消更新",
"app.update.checking": "正在检查更新…",
"app.update.disabled": "未配置自动更新",
"app.update.disabledBody": "设置更新地址(OPENIDEA_UPDATE_ENDPOINT 或 plugins.updater.endpoints)以启用更新检查。",