дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
fix(app): keep manual update check answers visible (#2385)
A manual update check could end without a visible answer: balloons were hidden whenever a saved layout listed Notifications as open, even when that tool window was not actually shown (welcome screen, hidden panel), and marking the list read dropped unlogged balloons. Balloons now defer only to an effectively visible Notifications tool window, and only listed entries are marked read. Covers update available, up to date and error in project, welcome and light-edit windows. Fixes #2285.
Цей коміт міститься в:
1 джерело
20be9ff646
коміт
658c38a193
5 files changed
+132
-11
No files matched your search
@@ -1,4 +1,40 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import { expect, stubDesktop, test } from "./fixtures";
|
||||
|
||||
for (const windowKind of ["project", "welcome", "lightEdit"]) {
|
||||
test(`manual update answers stay visible in ${windowKind} with saved Notifications layout and logging off`, async ({
|
||||
workbench: page,
|
||||
}) => {
|
||||
await stubDesktop(page);
|
||||
await page.evaluate(async (kind) => {
|
||||
const projectPath = "/src/platform/project.ts";
|
||||
const layoutPath = "/src/workbench/toolwindows/store.ts";
|
||||
const updatesPath = "/src/features/app/updates.tsx";
|
||||
const { useProject } = await import(/* @vite-ignore */ projectPath);
|
||||
const { useToolWindowLayout } = await import(/* @vite-ignore */ layoutPath);
|
||||
const { checkForUpdates } = await import(/* @vite-ignore */ updatesPath);
|
||||
const settings = useProject.getState().settings;
|
||||
useProject.setState({
|
||||
...(kind === "project" ? {} : { project: null, lightEdit: kind === "lightEdit" }),
|
||||
settings: {
|
||||
...settings,
|
||||
notifications: { balloons: false, groups: { updates: { display: "none", log: false } } },
|
||||
},
|
||||
});
|
||||
useToolWindowLayout.setState({
|
||||
visible: { ...useToolWindowLayout.getState().visible, right: "notifications" },
|
||||
maximized: null,
|
||||
});
|
||||
window.__ipc.handlers.app_check_update = () => ({
|
||||
enabled: true,
|
||||
update: { version: "99.0.0", currentVersion: "0.1.40", notes: null, date: null },
|
||||
});
|
||||
window.__ipc.handlers.app_update_announcement = () => true;
|
||||
await checkForUpdates();
|
||||
}, windowKind);
|
||||
await expect(page.getByRole("status").filter({ hasText: "OpenIDEA 99.0.0 is available" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Install and Restart", exact: true })).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
test("Updates shows check status and keeps manual checks available when automatic checks are off", async ({
|
||||
workbench: page,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createElement } from "react";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import type { Settings, UpdateCheck, UpdateInfo, UpdateProgress } from "@/bindings";
|
||||
import type { NotificationService } from "@/workbench/contributions";
|
||||
|
||||
@@ -191,6 +193,84 @@ describe("Install and Restart", () => {
|
||||
});
|
||||
|
||||
describe("Check for Updates", () => {
|
||||
describe.each(["welcome", "lightEdit", "project"])("visible answers in %s windows", (windowKind) => {
|
||||
describe.each(["logged", "unlogged", "maximized"])("%s notifications", (mode) => {
|
||||
it.each(["available", "none", "error"])(
|
||||
"shows %s with a saved Notifications layout and popups off",
|
||||
async (outcome) => {
|
||||
const { notificationServiceImpl, useNotifications, dismissBalloon } =
|
||||
await import("@/workbench/notificationStore");
|
||||
const { Balloons, NotificationsToolWindow } = await import("@/workbench/Notifications");
|
||||
const { registerToolWindow } = await import("@/workbench/contributions");
|
||||
const { useToolWindowLayout } = await import("@/workbench/toolwindows/store");
|
||||
const previousLayout = useToolWindowLayout.getState();
|
||||
const dispose = registerToolWindow({
|
||||
id: "notifications",
|
||||
title: "Notifications",
|
||||
icon: "bell",
|
||||
anchor: "right",
|
||||
component: NotificationsToolWindow,
|
||||
});
|
||||
useProject.setState({
|
||||
project: windowKind === "project" ? ({ root: "/p", name: "p" } as never) : null,
|
||||
lightEdit: windowKind === "lightEdit",
|
||||
settings: {
|
||||
notifications: { balloons: false, groups: { updates: { display: "none", log: mode !== "unlogged" } } },
|
||||
} as unknown as Settings,
|
||||
});
|
||||
useToolWindowLayout.setState({
|
||||
visible: { ...previousLayout.visible, right: "notifications" },
|
||||
maximized: mode === "maximized" ? "left" : null,
|
||||
});
|
||||
useNotifications.setState({ items: [] });
|
||||
provideNotificationService(notificationServiceImpl);
|
||||
const view = render(createElement(Balloons));
|
||||
const list =
|
||||
windowKind === "project"
|
||||
? render(createElement("div", { hidden: mode === "maximized" }, createElement(NotificationsToolWindow)))
|
||||
: null;
|
||||
try {
|
||||
// A dismissed answer with the same key must not hide a later manual result.
|
||||
const { notify } = await import("@/workbench/notificationStore");
|
||||
act(() =>
|
||||
dismissBalloon(notify({ level: "info", title: "Old update", key: UPDATE_KEY, userInitiated: true })),
|
||||
);
|
||||
await act(async () => {
|
||||
const done = checkForUpdates();
|
||||
check.resolve(
|
||||
outcome === "error"
|
||||
? { status: "error", error: { kind: "unavailable", message: "offline" } }
|
||||
: ok({
|
||||
enabled: true,
|
||||
update:
|
||||
outcome === "available"
|
||||
? { version: "0.1.22", currentVersion: "0.1.21", notes: null, date: null }
|
||||
: null,
|
||||
}),
|
||||
);
|
||||
await done;
|
||||
});
|
||||
const title =
|
||||
outcome === "error"
|
||||
? "Update check failed"
|
||||
: outcome === "available"
|
||||
? "OpenIDEA 0.1.22 is available"
|
||||
: "OpenIDEA 0.1.21 is up to date";
|
||||
const answer = screen.getByRole(outcome === "error" ? "alert" : "status");
|
||||
expect(answer).toBeVisible();
|
||||
expect(answer).toHaveTextContent(title);
|
||||
} finally {
|
||||
view.unmount();
|
||||
list?.unmount();
|
||||
dispose();
|
||||
useNotifications.setState({ items: [] });
|
||||
useToolWindowLayout.setState(previousLayout);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a background task while checking and joins a running check", async () => {
|
||||
const first = checkForUpdates();
|
||||
void checkForUpdates();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { useTxTemplate, VerbatimTextContext } from "@/platform/i18n";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { EmptyState, IconButton, Toast, T } from "@/ui";
|
||||
import {
|
||||
balloonTimeout,
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
useNotifications,
|
||||
type IdeNotification,
|
||||
} from "./notificationStore";
|
||||
import { useToolWindowLayout } from "./toolwindows/store";
|
||||
import { useEffectiveVisible, useToolWindows } from "./toolwindows/ToolWindows";
|
||||
import styles from "./Notifications.module.css";
|
||||
|
||||
const MAX_BALLOONS = 3;
|
||||
@@ -101,15 +102,15 @@ function Balloon({ n }: { n: IdeNotification }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Bottom-right balloon stack (newest at the bottom); hidden while the Notifications tool window is open. */
|
||||
/** Bottom-right balloon stack (newest at the bottom); listed entries defer to the visible tool window. */
|
||||
export function Balloons() {
|
||||
const items = useNotifications((s) => s.items);
|
||||
const listOpen = useListVisible();
|
||||
const balloons = items
|
||||
.filter((n) => n.balloon)
|
||||
.filter((n) => n.balloon && (!listOpen || !n.logged))
|
||||
.slice(0, MAX_BALLOONS)
|
||||
.reverse();
|
||||
if (listOpen || !balloons.length) return null;
|
||||
if (!balloons.length) return null;
|
||||
return (
|
||||
<div className={styles.stack} aria-live="polite">
|
||||
{balloons.map((n) => (
|
||||
@@ -122,7 +123,9 @@ export function Balloons() {
|
||||
export const NOTIFICATIONS_TOOL_WINDOW = "notifications";
|
||||
|
||||
function useListVisible() {
|
||||
return useToolWindowLayout((s) => Object.values(s.visible).includes(NOTIFICATIONS_TOOL_WINDOW));
|
||||
const hasProject = useProject((s) => !!s.project);
|
||||
const visible = useEffectiveVisible(useToolWindows());
|
||||
return hasProject && Object.values(visible).includes(NOTIFICATIONS_TOOL_WINDOW);
|
||||
}
|
||||
|
||||
/** The Notifications tool window (right stripe, as in IDEA): history, newest first. */
|
||||
@@ -131,7 +134,7 @@ export function NotificationsToolWindow() {
|
||||
const all = useNotifications((s) => s.items);
|
||||
const items = all.filter((n) => n.logged);
|
||||
const visible = useListVisible();
|
||||
const unread = all.some((n) => !n.read || n.balloon);
|
||||
const unread = items.some((n) => !n.read || n.balloon);
|
||||
useEffect(() => {
|
||||
if (visible && unread) markAllNotificationsRead();
|
||||
}, [visible, unread]);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { emitLocalIdeEvent } from "@/platform/ipc";
|
||||
import { setLocale } from "@/platform/i18n";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { registerStatusItem, registerToolWindow, type StatusItemContribution } from "./contributions";
|
||||
import { orderStatusItems, StatusBar, TaskStatus } from "./StatusBar";
|
||||
import { orderTitleItems } from "./TitleBar";
|
||||
@@ -223,6 +224,8 @@ describe("notifications", () => {
|
||||
});
|
||||
|
||||
it("the Notifications tool window lists history, marks it read and hides balloons", () => {
|
||||
const previousProject = useProject.getState().project;
|
||||
useProject.setState({ project: { root: "/notifications", name: "notifications" } as never });
|
||||
const dispose = registerToolWindow({
|
||||
id: NOTIFICATIONS_TOOL_WINDOW,
|
||||
title: "Notifications",
|
||||
@@ -244,6 +247,7 @@ describe("notifications", () => {
|
||||
expect(screen.getAllByText("Hello")).toHaveLength(1);
|
||||
expect(useNotifications.getState().items.every((n) => n.read && !n.balloon)).toBe(true);
|
||||
dispose();
|
||||
useProject.setState({ project: previousProject });
|
||||
});
|
||||
|
||||
it("pops up the answer to a command the user ran even when balloons are off for its group", async () => {
|
||||
|
||||
@@ -185,12 +185,10 @@ export function clearProjectNotifications() {
|
||||
useNotifications.setState((s) => ({ items: s.items.filter((x) => x.group === "updates") }));
|
||||
}
|
||||
|
||||
/** The list was seen: everything is read and no longer shown as a balloon. */
|
||||
/** The list was seen: its entries are read; unlogged balloons were not shown there. */
|
||||
export function markAllNotificationsRead() {
|
||||
useNotifications.setState((s) => ({
|
||||
items: s.items
|
||||
.filter((x) => x.logged)
|
||||
.map((x) => (x.read && !x.balloon ? x : { ...x, read: true, balloon: false })),
|
||||
items: s.items.map((x) => (!x.logged || (x.read && !x.balloon) ? x : { ...x, read: true, balloon: false })),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Заблокувати користувача