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)以启用更新检查。",