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