From 1ab3fa3e6668da24df48cdc5ece44053add793a5 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 30 Jun 2026 10:27:44 +0000 Subject: [PATCH 1/3] feat: show announcement banners --- package.json | 10 + src/announcements/announcementBanners.ts | 324 ++++++++++++++++++ src/core/commandManager.ts | 1 + src/core/secretsManager.ts | 33 ++ src/extension.ts | 13 + .../announcements/announcementBanners.test.ts | 290 ++++++++++++++++ test/unit/core/secretsManager.test.ts | 28 ++ 7 files changed, 699 insertions(+) create mode 100644 src/announcements/announcementBanners.ts create mode 100644 test/unit/announcements/announcementBanners.test.ts diff --git a/package.json b/package.json index 08cfcf66d7..7439bbee67 100644 --- a/package.json +++ b/package.json @@ -459,6 +459,12 @@ "title": "Coder: Export Telemetry", "icon": "$(save)" }, + { + "command": "coder.viewAnnouncements", + "title": "View Announcements", + "category": "Coder", + "icon": "$(megaphone)" + }, { "command": "coder.openAppStatus", "title": "Open App Status", @@ -587,6 +593,10 @@ "command": "coder.exportTelemetry", "when": "true" }, + { + "command": "coder.viewAnnouncements", + "when": "coder.authenticated" + }, { "command": "coder.openAppStatus", "when": "false" diff --git a/src/announcements/announcementBanners.ts b/src/announcements/announcementBanners.ts new file mode 100644 index 0000000000..42dbf15f6d --- /dev/null +++ b/src/announcements/announcementBanners.ts @@ -0,0 +1,324 @@ +import { createHash } from "node:crypto"; +import * as vscode from "vscode"; + +import { type CoderApi } from "../api/coderApi"; +import { type SecretsManager } from "../core/secretsManager"; +import { + type SessionData, + type SessionState, +} from "../deployment/sessionStore"; +import { type Logger } from "../logging/logger"; +import { areNotificationsDisabled } from "../settings/notifications"; + +import type { + AppearanceConfig, + BannerConfig, +} from "coder/site/src/api/typesGenerated"; + +const REFRESH_INTERVAL_MS = 30 * 60 * 1000; +const VIEW_ACTION = "View"; +const POPUP_MESSAGE_MAX_LENGTH = 120; + +export type AnnouncementBannerSource = "announcement" | "service"; + +export interface ActiveAnnouncementBanner { + readonly source: AnnouncementBannerSource; + readonly message: string; + readonly backgroundColor?: string; + readonly key: string; +} + +interface RefreshOptions { + readonly notify: boolean; + readonly showErrors?: boolean; +} + +interface BannerFingerprintInput { + readonly source: AnnouncementBannerSource; + readonly message: string; + readonly backgroundColor?: string; +} + +export class AnnouncementBannerManager implements vscode.Disposable { + private readonly statusBarItem: vscode.StatusBarItem; + private readonly sessionChangeDisposable: vscode.Disposable; + private activeBanners: readonly ActiveAnnouncementBanner[] = []; + private refreshTimeout: NodeJS.Timeout | undefined; + private disposed = false; + + public constructor( + private readonly client: CoderApi, + private readonly sessionState: SessionState, + private readonly secretsManager: SecretsManager, + private readonly logger: Logger, + ) { + this.statusBarItem = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Left, + 998, + ); + this.statusBarItem.name = "Coder Announcements"; + this.statusBarItem.command = "coder.viewAnnouncements"; + this.sessionChangeDisposable = this.sessionState.onDidChange(() => { + this.handleSessionChange(); + }); + this.handleSessionChange(); + } + + public dispose(): void { + this.disposed = true; + this.cancelRefresh(); + this.sessionChangeDisposable.dispose(); + this.statusBarItem.dispose(); + } + + public refresh( + options: RefreshOptions = { notify: true }, + ): Promise { + if (this.disposed) { + return Promise.resolve(undefined); + } + + this.cancelRefresh(); + return this.runRefresh(options) + .catch((error: unknown) => { + this.logger.warn("Failed to refresh Coder announcements", error); + if (options.showErrors) { + void vscode.window.showErrorMessage( + `Failed to refresh Coder announcements: ${formatError(error)}`, + ); + } + return undefined; + }) + .finally(() => { + this.scheduleRefresh(); + }); + } + + public async showAnnouncements(): Promise { + const banners = + (await this.refresh({ notify: false, showErrors: true })) ?? + this.activeBanners; + + if (banners.length === 0) { + void vscode.window.showInformationMessage( + "No active Coder announcements.", + ); + return; + } + + const selected = await vscode.window.showQuickPick( + banners.map((banner, index) => ({ + label: `${sourceIcon(banner.source)} ${sourceLabel(banner.source)} ${index + 1}`, + detail: banner.message, + description: banner.backgroundColor, + banner, + })), + { + title: "Coder Announcements", + placeHolder: "Select an announcement to view the full message", + }, + ); + + if (selected) { + void vscode.window.showInformationMessage(selected.banner.message); + } + } + + private handleSessionChange(): void { + this.cancelRefresh(); + if (this.sessionState.current.kind !== "signedIn") { + this.setActiveBanners([]); + return; + } + + this.setActiveBanners([]); + void this.refresh({ notify: true }); + } + + private async runRefresh( + options: RefreshOptions, + ): Promise { + const session = this.sessionState.current; + if (session.kind !== "signedIn") { + this.setActiveBanners([]); + return []; + } + + const appearance = await this.client.getAppearance(); + if (this.sessionChangedSince(session)) { + return undefined; + } + + const banners = normalizeAnnouncementBanners(appearance); + this.setActiveBanners(banners); + + const seen = new Set( + this.secretsManager.getSeenBanners(session.deployment.safeHostname), + ); + const newBanners = banners.filter((banner) => !seen.has(banner.key)); + + const cfg = vscode.workspace.getConfiguration(); + if ( + options.notify && + newBanners.length > 0 && + !areNotificationsDisabled(cfg) + ) { + this.showPopup(newBanners); + } + + await this.secretsManager.setSeenBanners( + session.deployment.safeHostname, + banners.map((banner) => banner.key), + ); + + return banners; + } + + private sessionChangedSince(session: SessionData): boolean { + return this.disposed || this.sessionState.current !== session; + } + + private setActiveBanners(banners: readonly ActiveAnnouncementBanner[]): void { + this.activeBanners = banners; + if (banners.length === 0) { + this.statusBarItem.hide(); + return; + } + + this.statusBarItem.text = formatStatusBarText(banners.length); + this.statusBarItem.tooltip = formatStatusBarTooltip(banners); + this.statusBarItem.show(); + } + + private showPopup(banners: readonly ActiveAnnouncementBanner[]): void { + const message = formatPopupMessage(banners); + void Promise.resolve( + vscode.window.showInformationMessage(message, VIEW_ACTION), + ) + .then((action) => { + if (action === VIEW_ACTION) { + void this.showAnnouncements(); + } + }) + .catch((error: unknown) => { + this.logger.warn("Failed to show Coder announcement popup", error); + }); + } + + private scheduleRefresh(): void { + if ( + this.disposed || + this.refreshTimeout || + this.sessionState.current.kind !== "signedIn" + ) { + return; + } + + this.refreshTimeout = setTimeout(() => { + this.refreshTimeout = undefined; + void this.refresh({ notify: true }); + }, REFRESH_INTERVAL_MS); + } + + private cancelRefresh(): void { + if (this.refreshTimeout) { + clearTimeout(this.refreshTimeout); + this.refreshTimeout = undefined; + } + } +} + +export function normalizeAnnouncementBanners( + appearance: AppearanceConfig, +): readonly ActiveAnnouncementBanner[] { + const banners: ActiveAnnouncementBanner[] = []; + const serviceBanner = toActiveBanner("service", appearance.service_banner); + if (serviceBanner) { + banners.push(serviceBanner); + } + + for (const banner of appearance.announcement_banners) { + const activeBanner = toActiveBanner("announcement", banner); + if (activeBanner) { + banners.push(activeBanner); + } + } + + return banners; +} + +function toActiveBanner( + source: AnnouncementBannerSource, + banner: BannerConfig, +): ActiveAnnouncementBanner | undefined { + const message = banner.message?.trim(); + if (!banner.enabled || !message) { + return undefined; + } + const backgroundColor = banner.background_color?.trim(); + return { + source, + message, + backgroundColor: backgroundColor || undefined, + key: getBannerKey({ source, message, backgroundColor }), + }; +} + +export function getBannerKey(input: BannerFingerprintInput): string { + return createHash("sha256") + .update( + JSON.stringify({ + source: input.source, + message: input.message, + backgroundColor: input.backgroundColor ?? "", + }), + ) + .digest("hex") + .slice(0, 16); +} + +function formatStatusBarText(count: number): string { + return count === 1 ? "$(megaphone) Coder" : `$(megaphone) Coder ${count}`; +} + +function formatStatusBarTooltip( + banners: readonly ActiveAnnouncementBanner[], +): string { + const heading = + banners.length === 1 + ? "Coder deployment announcement" + : "Coder deployment announcements"; + return [ + heading, + "", + ...banners.map((banner, index) => `${index + 1}. ${banner.message}`), + ].join("\n"); +} + +function formatPopupMessage( + banners: readonly ActiveAnnouncementBanner[], +): string { + if (banners.length === 1) { + return `Coder announcement: ${truncateMessage(banners[0].message)}`; + } + return `Coder has ${banners.length} new deployment announcements.`; +} + +function truncateMessage(message: string): string { + if (message.length <= POPUP_MESSAGE_MAX_LENGTH) { + return message; + } + return `${message.slice(0, POPUP_MESSAGE_MAX_LENGTH - 1)}…`; +} + +function sourceIcon(source: AnnouncementBannerSource): string { + return source === "service" ? "$(info)" : "$(megaphone)"; +} + +function sourceLabel(source: AnnouncementBannerSource): string { + return source === "service" ? "Service banner" : "Announcement"; +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/core/commandManager.ts b/src/core/commandManager.ts index 723a4fe21b..c9f721c3cd 100644 --- a/src/core/commandManager.ts +++ b/src/core/commandManager.ts @@ -21,6 +21,7 @@ export const CODER_COMMAND_IDS = [ "coder.refreshWorkspaces", "coder.viewLogs", "coder.exportTelemetry", + "coder.viewAnnouncements", "coder.searchMyWorkspaces", "coder.searchSharedWorkspaces", "coder.searchAllWorkspaces", diff --git a/src/core/secretsManager.ts b/src/core/secretsManager.ts index 5255c122c8..a8004abd12 100644 --- a/src/core/secretsManager.ts +++ b/src/core/secretsManager.ts @@ -17,6 +17,7 @@ const DEPLOYMENT_ACCESS_PREFIX = "coder.access."; type SecretKeyPrefix = typeof SESSION_KEY_PREFIX | typeof OAUTH_CLIENT_PREFIX; const CURRENT_DEPLOYMENT_KEY = "coder.currentDeployment"; +const SEEN_BANNERS_KEY = "seenBanners"; const DEFAULT_MAX_DEPLOYMENTS = 10; const LEGACY_SESSION_TOKEN_KEY = "sessionToken"; @@ -29,6 +30,9 @@ export type CurrentDeploymentState = z.infer< typeof CurrentDeploymentStateSchema >; +const SeenBannersSchema = z.record(z.string(), z.array(z.string())); +type SeenBanners = z.infer; + /** * OAuth token data stored alongside session auth. * When present, indicates the session is authenticated via OAuth. @@ -235,6 +239,7 @@ export class SecretsManager { await Promise.all([ this.clearSessionAuth(safeHostname), this.clearOAuthClientRegistration(safeHostname), + this.clearSeenBanners(safeHostname), this.memento.update( `${DEPLOYMENT_ACCESS_PREFIX}${safeHostname}`, undefined, @@ -242,6 +247,34 @@ export class SecretsManager { ]); } + public getSeenBanners(safeHostname: string): string[] { + return this.getSeenBannersState()[safeHostname] ?? []; + } + + public async setSeenBanners( + safeHostname: string, + bannerKeys: readonly string[], + ): Promise { + const seenBanners = this.getSeenBannersState(); + seenBanners[safeHostname] = [...bannerKeys]; + await this.memento.update(SEEN_BANNERS_KEY, seenBanners); + } + + private async clearSeenBanners(safeHostname: string): Promise { + const seenBanners = this.getSeenBannersState(); + delete seenBanners[safeHostname]; + await this.memento.update( + SEEN_BANNERS_KEY, + Object.keys(seenBanners).length > 0 ? seenBanners : undefined, + ); + } + + private getSeenBannersState(): SeenBanners { + const raw = this.memento.get(SEEN_BANNERS_KEY); + const result = SeenBannersSchema.safeParse(raw); + return result.success ? result.data : {}; + } + /** * Get all known hostnames, ordered by most recently accessed. * Derives the list from actual session secrets stored. diff --git a/src/extension.ts b/src/extension.ts index e097daa876..8417a71e77 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -6,6 +6,7 @@ import { createRequire } from "node:module"; import * as path from "node:path"; import * as vscode from "vscode"; +import { AnnouncementBannerManager } from "./announcements/announcementBanners"; import { errToStr } from "./api/api-helper"; import { AuthInterceptor } from "./api/authInterceptor"; import { CoderApi } from "./api/coderApi"; @@ -162,6 +163,14 @@ async function doActivate( ); ctx.subscriptions.push(deploymentManager); + const announcementBannerManager = new AnnouncementBannerManager( + client, + deploymentManager.session, + secretsManager, + output, + ); + ctx.subscriptions.push(announcementBannerManager); + const myWorkspacesProvider = new WorkspaceProvider( WorkspaceQuery.Mine, client, @@ -328,6 +337,10 @@ async function doActivate( "coder.exportTelemetry", commands.exportTelemetry.bind(commands), ); + commandManager.register( + "coder.viewAnnouncements", + announcementBannerManager.showAnnouncements.bind(announcementBannerManager), + ); commandManager.register("coder.searchMyWorkspaces", async () => showTreeViewSearch(MY_WORKSPACES_TREE_ID), ); diff --git a/test/unit/announcements/announcementBanners.test.ts b/test/unit/announcements/announcementBanners.test.ts new file mode 100644 index 0000000000..1d79086f50 --- /dev/null +++ b/test/unit/announcements/announcementBanners.test.ts @@ -0,0 +1,290 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; + +import { + AnnouncementBannerManager, + getBannerKey, + normalizeAnnouncementBanners, +} from "@/announcements/announcementBanners"; +import { SecretsManager } from "@/core/secretsManager"; +import { SessionStore } from "@/deployment/sessionStore"; + +import { + createMockLogger, + createMockUser, + flushPromises, + InMemoryMemento, + InMemorySecretStorage, + MockConfigurationProvider, + MockStatusBarItem, +} from "../../mocks/testHelpers"; + +import type { + AppearanceConfig, + BannerConfig, +} from "coder/site/src/api/typesGenerated"; + +import type { CoderApi } from "@/api/coderApi"; + +const DEPLOYMENT = { + url: "https://coder.example.com", + safeHostname: "coder.example.com", +}; + +function banner(overrides: Partial = {}): BannerConfig { + return { + enabled: true, + message: "Maintenance tonight", + background_color: "#004852", + ...overrides, + }; +} + +function appearance( + overrides: Partial = {}, +): AppearanceConfig { + return { + application_name: "Coder", + logo_url: "", + docs_url: "", + service_banner: { enabled: false }, + announcement_banners: [], + ...overrides, + }; +} + +class MockAppearanceClient { + readonly getAppearance = vi.fn<() => Promise>(); +} + +function setup() { + const config = new MockConfigurationProvider(); + const client = new MockAppearanceClient(); + client.getAppearance.mockResolvedValue(appearance()); + const session = new SessionStore(); + const secretsManager = new SecretsManager( + new InMemorySecretStorage(), + new InMemoryMemento(), + createMockLogger(), + ); + const statusBar = new MockStatusBarItem(); + const logger = createMockLogger(); + const manager = new AnnouncementBannerManager( + client as unknown as CoderApi, + session, + secretsManager, + logger, + ); + return { + config, + client, + logger, + manager, + secretsManager, + session, + statusBar, + }; +} + +async function signIn(session: SessionStore): Promise { + session.signIn(DEPLOYMENT, createMockUser()); + await flushPromises(); +} + +describe("normalizeAnnouncementBanners", () => { + it("returns active service and announcement banners", () => { + const banners = normalizeAnnouncementBanners( + appearance({ + service_banner: banner({ message: "Service banner" }), + announcement_banners: [ + banner({ message: "Announcement" }), + banner({ enabled: false, message: "Disabled" }), + banner({ message: " " }), + ], + }), + ); + + expect(banners.map((b) => [b.source, b.message])).toEqual([ + ["service", "Service banner"], + ["announcement", "Announcement"], + ]); + }); + + it("keys ignore order but change when content changes", () => { + const key = getBannerKey({ + source: "announcement", + message: "Maintenance tonight", + backgroundColor: "#004852", + }); + + expect( + getBannerKey({ + source: "announcement", + message: "Maintenance tonight", + backgroundColor: "#004852", + }), + ).toBe(key); + expect( + getBannerKey({ + source: "announcement", + message: "Maintenance tomorrow", + backgroundColor: "#004852", + }), + ).not.toBe(key); + }); +}); + +describe("AnnouncementBannerManager", () => { + beforeEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it("shows all active banners in the status bar", async () => { + const { client, session, statusBar } = setup(); + client.getAppearance.mockResolvedValueOnce( + appearance({ + announcement_banners: [ + banner({ message: "First" }), + banner({ message: "Second" }), + ], + }), + ); + + await signIn(session); + + expect(statusBar.text).toBe("$(megaphone) Coder 2"); + expect(statusBar.tooltip).toContain("1. First"); + expect(statusBar.tooltip).toContain("2. Second"); + expect(statusBar.show).toHaveBeenCalled(); + }); + + it("notifies only newly seen banners", async () => { + const { client, manager, secretsManager, session } = setup(); + client.getAppearance.mockResolvedValueOnce( + appearance({ + announcement_banners: [ + banner({ message: "First" }), + banner({ message: "Second" }), + ], + }), + ); + await signIn(session); + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + "Coder has 2 new deployment announcements.", + "View", + ); + vi.mocked(vscode.window.showInformationMessage).mockClear(); + + client.getAppearance.mockResolvedValueOnce( + appearance({ + announcement_banners: [ + banner({ message: "First" }), + banner({ message: "Second" }), + banner({ message: "Third" }), + ], + }), + ); + await manager.refresh({ notify: true }); + + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + "Coder announcement: Third", + "View", + ); + expect(secretsManager.getSeenBanners(DEPLOYMENT.safeHostname)).toHaveLength( + 3, + ); + }); + + it("does not notify for banners already seen on the same deployment", async () => { + const { client, manager, session } = setup(); + client.getAppearance.mockResolvedValue( + appearance({ announcement_banners: [banner()] }), + ); + await signIn(session); + vi.mocked(vscode.window.showInformationMessage).mockClear(); + + await manager.refresh({ notify: true }); + + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + }); + + it("suppresses popups when notifications are disabled but keeps status bar", async () => { + const { client, config, session, statusBar } = setup(); + config.set("coder.disableNotifications", true); + client.getAppearance.mockResolvedValueOnce( + appearance({ announcement_banners: [banner()] }), + ); + + await signIn(session); + + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + expect(statusBar.show).toHaveBeenCalled(); + expect(statusBar.text).toBe("$(megaphone) Coder"); + }); + + it("shows newly seen banners on a different deployment", async () => { + const { client, session } = setup(); + client.getAppearance.mockResolvedValue( + appearance({ announcement_banners: [banner()] }), + ); + + session.signIn(DEPLOYMENT, createMockUser()); + await Promise.resolve(); + vi.mocked(vscode.window.showInformationMessage).mockClear(); + + session.signIn( + { url: "https://other.example.com", safeHostname: "other.example.com" }, + createMockUser(), + ); + await flushPromises(); + + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + "Coder announcement: Maintenance tonight", + "View", + ); + }); + + it("refreshes before showing announcements from the command", async () => { + const { client, manager, session } = setup(); + client.getAppearance.mockResolvedValue( + appearance({ + announcement_banners: [banner({ message: "Full details" })], + }), + ); + vi.mocked(vscode.window.showQuickPick).mockResolvedValueOnce({ + label: "$(megaphone) Announcement 1", + detail: "Full details", + banner: { + source: "announcement", + message: "Full details", + backgroundColor: "#004852", + key: "key", + }, + } as never); + await signIn(session); + vi.mocked(vscode.window.showInformationMessage).mockClear(); + + await manager.showAnnouncements(); + + expect(vscode.window.showQuickPick).toHaveBeenCalledWith( + [expect.objectContaining({ detail: "Full details" })], + expect.objectContaining({ title: "Coder Announcements" }), + ); + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + "Full details", + ); + }); + + it("clears status bar when signed out", async () => { + const { client, session, statusBar } = setup(); + client.getAppearance.mockResolvedValueOnce( + appearance({ announcement_banners: [banner()] }), + ); + await signIn(session); + + session.signOut(null); + + expect(statusBar.hide).toHaveBeenCalled(); + }); +}); diff --git a/test/unit/core/secretsManager.test.ts b/test/unit/core/secretsManager.test.ts index 9997b89519..3cee2dac0e 100644 --- a/test/unit/core/secretsManager.test.ts +++ b/test/unit/core/secretsManager.test.ts @@ -170,6 +170,34 @@ describe("SecretsManager", () => { vi.useRealTimers(); }); + describe("seen banners", () => { + it("stores seen banner keys by safe hostname", async () => { + await secretsManager.setSeenBanners("example.com", ["one", "two"]); + await secretsManager.setSeenBanners("other.com", ["three"]); + + expect(secretsManager.getSeenBanners("example.com")).toEqual([ + "one", + "two", + ]); + expect(secretsManager.getSeenBanners("other.com")).toEqual(["three"]); + }); + + it("clears seen banner keys with auth data", async () => { + await secretsManager.setSeenBanners("example.com", ["one"]); + await secretsManager.setSeenBanners("other.com", ["two"]); + + await secretsManager.clearAllAuthData("example.com"); + + expect(secretsManager.getSeenBanners("example.com")).toEqual([]); + expect(secretsManager.getSeenBanners("other.com")).toEqual(["two"]); + }); + + it("ignores corrupted seen banner storage", async () => { + await memento.update("seenBanners", { "example.com": "bad" }); + + expect(secretsManager.getSeenBanners("example.com")).toEqual([]); + }); + }); }); describe("current deployment", () => { From d5d5cb72d15e3d8f87d6f011b08ce3ff20984490 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 30 Jun 2026 10:45:52 +0000 Subject: [PATCH 2/3] refactor: simplify announcement banner modules --- src/announcements/announcementBanners.ts | 324 ------------------ src/announcements/banners.ts | 94 +++++ src/announcements/manager.ts | 205 +++++++++++ src/extension.ts | 8 +- test/unit/announcements/banners.test.ts | 158 +++++++++ ...ncementBanners.test.ts => manager.test.ts} | 232 ++++++------- 6 files changed, 572 insertions(+), 449 deletions(-) delete mode 100644 src/announcements/announcementBanners.ts create mode 100644 src/announcements/banners.ts create mode 100644 src/announcements/manager.ts create mode 100644 test/unit/announcements/banners.test.ts rename test/unit/announcements/{announcementBanners.test.ts => manager.test.ts} (55%) diff --git a/src/announcements/announcementBanners.ts b/src/announcements/announcementBanners.ts deleted file mode 100644 index 42dbf15f6d..0000000000 --- a/src/announcements/announcementBanners.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { createHash } from "node:crypto"; -import * as vscode from "vscode"; - -import { type CoderApi } from "../api/coderApi"; -import { type SecretsManager } from "../core/secretsManager"; -import { - type SessionData, - type SessionState, -} from "../deployment/sessionStore"; -import { type Logger } from "../logging/logger"; -import { areNotificationsDisabled } from "../settings/notifications"; - -import type { - AppearanceConfig, - BannerConfig, -} from "coder/site/src/api/typesGenerated"; - -const REFRESH_INTERVAL_MS = 30 * 60 * 1000; -const VIEW_ACTION = "View"; -const POPUP_MESSAGE_MAX_LENGTH = 120; - -export type AnnouncementBannerSource = "announcement" | "service"; - -export interface ActiveAnnouncementBanner { - readonly source: AnnouncementBannerSource; - readonly message: string; - readonly backgroundColor?: string; - readonly key: string; -} - -interface RefreshOptions { - readonly notify: boolean; - readonly showErrors?: boolean; -} - -interface BannerFingerprintInput { - readonly source: AnnouncementBannerSource; - readonly message: string; - readonly backgroundColor?: string; -} - -export class AnnouncementBannerManager implements vscode.Disposable { - private readonly statusBarItem: vscode.StatusBarItem; - private readonly sessionChangeDisposable: vscode.Disposable; - private activeBanners: readonly ActiveAnnouncementBanner[] = []; - private refreshTimeout: NodeJS.Timeout | undefined; - private disposed = false; - - public constructor( - private readonly client: CoderApi, - private readonly sessionState: SessionState, - private readonly secretsManager: SecretsManager, - private readonly logger: Logger, - ) { - this.statusBarItem = vscode.window.createStatusBarItem( - vscode.StatusBarAlignment.Left, - 998, - ); - this.statusBarItem.name = "Coder Announcements"; - this.statusBarItem.command = "coder.viewAnnouncements"; - this.sessionChangeDisposable = this.sessionState.onDidChange(() => { - this.handleSessionChange(); - }); - this.handleSessionChange(); - } - - public dispose(): void { - this.disposed = true; - this.cancelRefresh(); - this.sessionChangeDisposable.dispose(); - this.statusBarItem.dispose(); - } - - public refresh( - options: RefreshOptions = { notify: true }, - ): Promise { - if (this.disposed) { - return Promise.resolve(undefined); - } - - this.cancelRefresh(); - return this.runRefresh(options) - .catch((error: unknown) => { - this.logger.warn("Failed to refresh Coder announcements", error); - if (options.showErrors) { - void vscode.window.showErrorMessage( - `Failed to refresh Coder announcements: ${formatError(error)}`, - ); - } - return undefined; - }) - .finally(() => { - this.scheduleRefresh(); - }); - } - - public async showAnnouncements(): Promise { - const banners = - (await this.refresh({ notify: false, showErrors: true })) ?? - this.activeBanners; - - if (banners.length === 0) { - void vscode.window.showInformationMessage( - "No active Coder announcements.", - ); - return; - } - - const selected = await vscode.window.showQuickPick( - banners.map((banner, index) => ({ - label: `${sourceIcon(banner.source)} ${sourceLabel(banner.source)} ${index + 1}`, - detail: banner.message, - description: banner.backgroundColor, - banner, - })), - { - title: "Coder Announcements", - placeHolder: "Select an announcement to view the full message", - }, - ); - - if (selected) { - void vscode.window.showInformationMessage(selected.banner.message); - } - } - - private handleSessionChange(): void { - this.cancelRefresh(); - if (this.sessionState.current.kind !== "signedIn") { - this.setActiveBanners([]); - return; - } - - this.setActiveBanners([]); - void this.refresh({ notify: true }); - } - - private async runRefresh( - options: RefreshOptions, - ): Promise { - const session = this.sessionState.current; - if (session.kind !== "signedIn") { - this.setActiveBanners([]); - return []; - } - - const appearance = await this.client.getAppearance(); - if (this.sessionChangedSince(session)) { - return undefined; - } - - const banners = normalizeAnnouncementBanners(appearance); - this.setActiveBanners(banners); - - const seen = new Set( - this.secretsManager.getSeenBanners(session.deployment.safeHostname), - ); - const newBanners = banners.filter((banner) => !seen.has(banner.key)); - - const cfg = vscode.workspace.getConfiguration(); - if ( - options.notify && - newBanners.length > 0 && - !areNotificationsDisabled(cfg) - ) { - this.showPopup(newBanners); - } - - await this.secretsManager.setSeenBanners( - session.deployment.safeHostname, - banners.map((banner) => banner.key), - ); - - return banners; - } - - private sessionChangedSince(session: SessionData): boolean { - return this.disposed || this.sessionState.current !== session; - } - - private setActiveBanners(banners: readonly ActiveAnnouncementBanner[]): void { - this.activeBanners = banners; - if (banners.length === 0) { - this.statusBarItem.hide(); - return; - } - - this.statusBarItem.text = formatStatusBarText(banners.length); - this.statusBarItem.tooltip = formatStatusBarTooltip(banners); - this.statusBarItem.show(); - } - - private showPopup(banners: readonly ActiveAnnouncementBanner[]): void { - const message = formatPopupMessage(banners); - void Promise.resolve( - vscode.window.showInformationMessage(message, VIEW_ACTION), - ) - .then((action) => { - if (action === VIEW_ACTION) { - void this.showAnnouncements(); - } - }) - .catch((error: unknown) => { - this.logger.warn("Failed to show Coder announcement popup", error); - }); - } - - private scheduleRefresh(): void { - if ( - this.disposed || - this.refreshTimeout || - this.sessionState.current.kind !== "signedIn" - ) { - return; - } - - this.refreshTimeout = setTimeout(() => { - this.refreshTimeout = undefined; - void this.refresh({ notify: true }); - }, REFRESH_INTERVAL_MS); - } - - private cancelRefresh(): void { - if (this.refreshTimeout) { - clearTimeout(this.refreshTimeout); - this.refreshTimeout = undefined; - } - } -} - -export function normalizeAnnouncementBanners( - appearance: AppearanceConfig, -): readonly ActiveAnnouncementBanner[] { - const banners: ActiveAnnouncementBanner[] = []; - const serviceBanner = toActiveBanner("service", appearance.service_banner); - if (serviceBanner) { - banners.push(serviceBanner); - } - - for (const banner of appearance.announcement_banners) { - const activeBanner = toActiveBanner("announcement", banner); - if (activeBanner) { - banners.push(activeBanner); - } - } - - return banners; -} - -function toActiveBanner( - source: AnnouncementBannerSource, - banner: BannerConfig, -): ActiveAnnouncementBanner | undefined { - const message = banner.message?.trim(); - if (!banner.enabled || !message) { - return undefined; - } - const backgroundColor = banner.background_color?.trim(); - return { - source, - message, - backgroundColor: backgroundColor || undefined, - key: getBannerKey({ source, message, backgroundColor }), - }; -} - -export function getBannerKey(input: BannerFingerprintInput): string { - return createHash("sha256") - .update( - JSON.stringify({ - source: input.source, - message: input.message, - backgroundColor: input.backgroundColor ?? "", - }), - ) - .digest("hex") - .slice(0, 16); -} - -function formatStatusBarText(count: number): string { - return count === 1 ? "$(megaphone) Coder" : `$(megaphone) Coder ${count}`; -} - -function formatStatusBarTooltip( - banners: readonly ActiveAnnouncementBanner[], -): string { - const heading = - banners.length === 1 - ? "Coder deployment announcement" - : "Coder deployment announcements"; - return [ - heading, - "", - ...banners.map((banner, index) => `${index + 1}. ${banner.message}`), - ].join("\n"); -} - -function formatPopupMessage( - banners: readonly ActiveAnnouncementBanner[], -): string { - if (banners.length === 1) { - return `Coder announcement: ${truncateMessage(banners[0].message)}`; - } - return `Coder has ${banners.length} new deployment announcements.`; -} - -function truncateMessage(message: string): string { - if (message.length <= POPUP_MESSAGE_MAX_LENGTH) { - return message; - } - return `${message.slice(0, POPUP_MESSAGE_MAX_LENGTH - 1)}…`; -} - -function sourceIcon(source: AnnouncementBannerSource): string { - return source === "service" ? "$(info)" : "$(megaphone)"; -} - -function sourceLabel(source: AnnouncementBannerSource): string { - return source === "service" ? "Service banner" : "Announcement"; -} - -function formatError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/src/announcements/banners.ts b/src/announcements/banners.ts new file mode 100644 index 0000000000..a46b1bb570 --- /dev/null +++ b/src/announcements/banners.ts @@ -0,0 +1,94 @@ +import { createHash } from "node:crypto"; + +import type { + AppearanceConfig, + BannerConfig, +} from "coder/site/src/api/typesGenerated"; + +const POPUP_MESSAGE_MAX_LENGTH = 120; + +export type AnnouncementSource = "announcement" | "service"; + +export interface Announcement { + readonly source: AnnouncementSource; + readonly message: string; + readonly backgroundColor?: string; + readonly key: string; +} + +export function normalizeBanners( + appearance: AppearanceConfig, +): readonly Announcement[] { + return [ + toAnnouncement("service", appearance.service_banner), + ...appearance.announcement_banners.map((banner) => + toAnnouncement("announcement", banner), + ), + ].filter((banner): banner is Announcement => banner !== undefined); +} + +export function bannerKey( + banner: Pick, +): string { + return createHash("sha256") + .update( + JSON.stringify({ + source: banner.source, + message: banner.message, + backgroundColor: banner.backgroundColor ?? "", + }), + ) + .digest("hex") + .slice(0, 16); +} + +export function statusText(count: number): string { + return count === 1 ? "$(megaphone) Coder" : `$(megaphone) Coder ${count}`; +} + +export function statusTooltip(banners: readonly Announcement[]): string { + return [ + banners.length === 1 + ? "Coder deployment announcement" + : "Coder deployment announcements", + "", + ...banners.map((banner, index) => `${index + 1}. ${banner.message}`), + ].join("\n"); +} + +export function popupMessage(banners: readonly Announcement[]): string { + return banners.length === 1 + ? `Coder announcement: ${truncate(banners[0].message)}` + : `Coder has ${banners.length} new deployment announcements.`; +} + +export function sourceLabel(source: AnnouncementSource): string { + return source === "service" ? "Service banner" : "Announcement"; +} + +export function sourceIcon(source: AnnouncementSource): string { + return source === "service" ? "$(info)" : "$(megaphone)"; +} + +function toAnnouncement( + source: AnnouncementSource, + banner: BannerConfig, +): Announcement | undefined { + const message = banner.message?.trim(); + const backgroundColor = banner.background_color?.trim() || undefined; + if (!banner.enabled || !message) { + return undefined; + } + return { + source, + message, + backgroundColor, + key: bannerKey({ source, message, backgroundColor }), + }; +} + +function truncate(message: string): string { + return message.length <= POPUP_MESSAGE_MAX_LENGTH + ? message + : `${message.slice(0, POPUP_MESSAGE_MAX_LENGTH - 1)}…`; +} diff --git a/src/announcements/manager.ts b/src/announcements/manager.ts new file mode 100644 index 0000000000..bfd9ac0a9f --- /dev/null +++ b/src/announcements/manager.ts @@ -0,0 +1,205 @@ +import * as vscode from "vscode"; + +import { type CoderApi } from "../api/coderApi"; +import { type SecretsManager } from "../core/secretsManager"; +import { + type SessionData, + type SessionState, +} from "../deployment/sessionStore"; +import { type Logger } from "../logging/logger"; +import { areNotificationsDisabled } from "../settings/notifications"; + +import { + type Announcement, + normalizeBanners, + popupMessage, + sourceIcon, + sourceLabel, + statusText, + statusTooltip, +} from "./banners"; + +const REFRESH_INTERVAL_MS = 30 * 60 * 1000; +const VIEW_ACTION = "View"; + +interface RefreshOptions { + readonly notify?: boolean; + readonly showErrors?: boolean; +} + +export class AnnouncementManager implements vscode.Disposable { + private readonly statusBarItem: vscode.StatusBarItem; + private readonly sessionChangeDisposable: vscode.Disposable; + private banners: readonly Announcement[] = []; + private refreshTimeout: NodeJS.Timeout | undefined; + private disposed = false; + + public constructor( + private readonly client: Pick, + private readonly sessionState: SessionState, + private readonly secretsManager: SecretsManager, + private readonly logger: Logger, + ) { + this.statusBarItem = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Left, + 998, + ); + this.statusBarItem.name = "Coder Announcements"; + this.statusBarItem.command = "coder.viewAnnouncements"; + this.sessionChangeDisposable = this.sessionState.onDidChange(() => { + this.onSessionChange(); + }); + this.onSessionChange(); + } + + public dispose(): void { + this.disposed = true; + this.cancelRefresh(); + this.sessionChangeDisposable.dispose(); + this.statusBarItem.dispose(); + } + + public async refresh( + options: RefreshOptions = {}, + ): Promise { + if (this.disposed) { + return undefined; + } + this.cancelRefresh(); + try { + return await this.fetch(options); + } catch (error) { + this.logger.warn("Failed to refresh Coder announcements", error); + if (options.showErrors) { + void vscode.window.showErrorMessage( + `Failed to refresh Coder announcements: ${errorMessage(error)}`, + ); + } + return undefined; + } finally { + this.scheduleRefresh(); + } + } + + public async showAnnouncements(): Promise { + const banners = + (await this.refresh({ notify: false, showErrors: true })) ?? this.banners; + if (banners.length === 0) { + void vscode.window.showInformationMessage( + "No active Coder announcements.", + ); + return; + } + + const selected = await vscode.window.showQuickPick( + banners.map((banner, index) => ({ + label: `${sourceIcon(banner.source)} ${sourceLabel(banner.source)} ${index + 1}`, + detail: banner.message, + description: banner.backgroundColor, + banner, + })), + { + title: "Coder Announcements", + placeHolder: "Select an announcement to view the full message", + }, + ); + if (selected) { + void vscode.window.showInformationMessage(selected.banner.message); + } + } + + private onSessionChange(): void { + this.cancelRefresh(); + this.setBanners([]); + if (this.sessionState.current.kind === "signedIn") { + void this.refresh({ notify: true }); + } + } + + private async fetch( + options: RefreshOptions, + ): Promise { + const session = this.sessionState.current; + if (session.kind !== "signedIn") { + this.setBanners([]); + return []; + } + + const banners = normalizeBanners(await this.client.getAppearance()); + if (this.sessionChangedSince(session)) { + return undefined; + } + this.setBanners(banners); + + const seen = new Set( + this.secretsManager.getSeenBanners(session.deployment.safeHostname), + ); + const unseen = banners.filter((banner) => !seen.has(banner.key)); + if (options.notify && unseen.length > 0 && notificationsEnabled()) { + this.showPopup(unseen); + } + await this.secretsManager.setSeenBanners( + session.deployment.safeHostname, + banners.map((banner) => banner.key), + ); + return banners; + } + + private setBanners(banners: readonly Announcement[]): void { + this.banners = banners; + if (banners.length === 0) { + this.statusBarItem.hide(); + return; + } + this.statusBarItem.text = statusText(banners.length); + this.statusBarItem.tooltip = statusTooltip(banners); + this.statusBarItem.show(); + } + + private showPopup(banners: readonly Announcement[]): void { + void Promise.resolve( + vscode.window.showInformationMessage(popupMessage(banners), VIEW_ACTION), + ) + .then((action) => { + if (action === VIEW_ACTION) { + void this.showAnnouncements(); + } + }) + .catch((error: unknown) => { + this.logger.warn("Failed to show Coder announcement popup", error); + }); + } + + private scheduleRefresh(): void { + if ( + this.disposed || + this.refreshTimeout || + this.sessionState.current.kind !== "signedIn" + ) { + return; + } + this.refreshTimeout = setTimeout(() => { + this.refreshTimeout = undefined; + void this.refresh({ notify: true }); + }, REFRESH_INTERVAL_MS); + } + + private cancelRefresh(): void { + if (this.refreshTimeout) { + clearTimeout(this.refreshTimeout); + this.refreshTimeout = undefined; + } + } + + private sessionChangedSince(session: SessionData): boolean { + return this.disposed || this.sessionState.current !== session; + } +} + +function notificationsEnabled(): boolean { + return !areNotificationsDisabled(vscode.workspace.getConfiguration()); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/extension.ts b/src/extension.ts index 8417a71e77..541331562a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -6,7 +6,7 @@ import { createRequire } from "node:module"; import * as path from "node:path"; import * as vscode from "vscode"; -import { AnnouncementBannerManager } from "./announcements/announcementBanners"; +import { AnnouncementManager } from "./announcements/manager"; import { errToStr } from "./api/api-helper"; import { AuthInterceptor } from "./api/authInterceptor"; import { CoderApi } from "./api/coderApi"; @@ -163,13 +163,13 @@ async function doActivate( ); ctx.subscriptions.push(deploymentManager); - const announcementBannerManager = new AnnouncementBannerManager( + const announcementManager = new AnnouncementManager( client, deploymentManager.session, secretsManager, output, ); - ctx.subscriptions.push(announcementBannerManager); + ctx.subscriptions.push(announcementManager); const myWorkspacesProvider = new WorkspaceProvider( WorkspaceQuery.Mine, @@ -339,7 +339,7 @@ async function doActivate( ); commandManager.register( "coder.viewAnnouncements", - announcementBannerManager.showAnnouncements.bind(announcementBannerManager), + announcementManager.showAnnouncements.bind(announcementManager), ); commandManager.register("coder.searchMyWorkspaces", async () => showTreeViewSearch(MY_WORKSPACES_TREE_ID), diff --git a/test/unit/announcements/banners.test.ts b/test/unit/announcements/banners.test.ts new file mode 100644 index 0000000000..9edca9420e --- /dev/null +++ b/test/unit/announcements/banners.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest"; + +import { + bannerKey, + normalizeBanners, + popupMessage, + sourceIcon, + sourceLabel, + statusText, + statusTooltip, +} from "@/announcements/banners"; + +import type { + AppearanceConfig, + BannerConfig, +} from "coder/site/src/api/typesGenerated"; + +function banner(overrides: Partial = {}): BannerConfig { + return { + enabled: true, + message: "Maintenance tonight", + background_color: "#004852", + ...overrides, + }; +} + +function appearance( + overrides: Partial = {}, +): AppearanceConfig { + return { + application_name: "Coder", + logo_url: "", + docs_url: "", + service_banner: { enabled: false }, + announcement_banners: [], + ...overrides, + }; +} + +function announcements(...messages: string[]) { + return normalizeBanners( + appearance({ + announcement_banners: messages.map((message) => banner({ message })), + }), + ); +} + +describe("normalizeBanners", () => { + it("returns active service and announcement banners", () => { + const banners = normalizeBanners( + appearance({ + service_banner: banner({ + message: " Service banner ", + background_color: " #123456 ", + }), + announcement_banners: [ + banner({ message: " Announcement " }), + banner({ enabled: false, message: "Disabled" }), + banner({ message: " " }), + ], + }), + ); + + expect(banners).toMatchObject([ + { + source: "service", + message: "Service banner", + backgroundColor: "#123456", + }, + { + source: "announcement", + message: "Announcement", + backgroundColor: "#004852", + }, + ]); + expect(banners).toHaveLength(2); + expect(banners[0].key).toBe( + bannerKey({ + source: "service", + message: "Service banner", + backgroundColor: "#123456", + }), + ); + }); + + it("keeps keys stable when banners reorder", () => { + const original = announcements("First", "Second"); + const reordered = announcements("Second", "First"); + const keyFor = (message: string, banners = original) => + banners.find((banner) => banner.message === message)?.key; + + expect(keyFor("First", reordered)).toBe(keyFor("First")); + expect(keyFor("Second", reordered)).toBe(keyFor("Second")); + }); + + it("changes keys when fingerprint fields change", () => { + const key = bannerKey({ + source: "announcement", + message: "Maintenance tonight", + backgroundColor: "#004852", + }); + + expect( + bannerKey({ + source: "service", + message: "Maintenance tonight", + backgroundColor: "#004852", + }), + ).not.toBe(key); + expect( + bannerKey({ + source: "announcement", + message: "Maintenance tomorrow", + backgroundColor: "#004852", + }), + ).not.toBe(key); + expect( + bannerKey({ + source: "announcement", + message: "Maintenance tonight", + backgroundColor: "#111111", + }), + ).not.toBe(key); + }); +}); + +describe("banner copy", () => { + it("formats status bar text and tooltip", () => { + const banners = announcements("First", "Second"); + + expect(statusText(1)).toBe("$(megaphone) Coder"); + expect(statusText(2)).toBe("$(megaphone) Coder 2"); + expect(statusTooltip(banners)).toBe( + "Coder deployment announcements\n\n1. First\n2. Second", + ); + }); + + it("formats popup messages", () => { + const longMessage = "a".repeat(121); + + expect(popupMessage(announcements("Maintenance tonight"))).toBe( + "Coder announcement: Maintenance tonight", + ); + expect(popupMessage(announcements("First", "Second"))).toBe( + "Coder has 2 new deployment announcements.", + ); + expect(popupMessage(announcements(longMessage))).toBe( + `Coder announcement: ${"a".repeat(119)}…`, + ); + }); + + it("formats QuickPick source labels", () => { + expect(sourceIcon("service")).toBe("$(info)"); + expect(sourceIcon("announcement")).toBe("$(megaphone)"); + expect(sourceLabel("service")).toBe("Service banner"); + expect(sourceLabel("announcement")).toBe("Announcement"); + }); +}); diff --git a/test/unit/announcements/announcementBanners.test.ts b/test/unit/announcements/manager.test.ts similarity index 55% rename from test/unit/announcements/announcementBanners.test.ts rename to test/unit/announcements/manager.test.ts index 1d79086f50..dbdbb853f8 100644 --- a/test/unit/announcements/announcementBanners.test.ts +++ b/test/unit/announcements/manager.test.ts @@ -1,11 +1,7 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as vscode from "vscode"; -import { - AnnouncementBannerManager, - getBannerKey, - normalizeAnnouncementBanners, -} from "@/announcements/announcementBanners"; +import { AnnouncementManager } from "@/announcements/manager"; import { SecretsManager } from "@/core/secretsManager"; import { SessionStore } from "@/deployment/sessionStore"; @@ -24,13 +20,17 @@ import type { BannerConfig, } from "coder/site/src/api/typesGenerated"; -import type { CoderApi } from "@/api/coderApi"; - const DEPLOYMENT = { url: "https://coder.example.com", safeHostname: "coder.example.com", }; +class MockAppearanceClient { + readonly getAppearance = vi.fn<() => Promise>(); +} + +const managers: AnnouncementManager[] = []; + function banner(overrides: Partial = {}): BannerConfig { return { enabled: true, @@ -40,23 +40,16 @@ function banner(overrides: Partial = {}): BannerConfig { }; } -function appearance( - overrides: Partial = {}, -): AppearanceConfig { +function appearance(messages: readonly string[] = []): AppearanceConfig { return { application_name: "Coder", logo_url: "", docs_url: "", service_banner: { enabled: false }, - announcement_banners: [], - ...overrides, + announcement_banners: messages.map((message) => banner({ message })), }; } -class MockAppearanceClient { - readonly getAppearance = vi.fn<() => Promise>(); -} - function setup() { const config = new MockConfigurationProvider(); const client = new MockAppearanceClient(); @@ -69,15 +62,16 @@ function setup() { ); const statusBar = new MockStatusBarItem(); const logger = createMockLogger(); - const manager = new AnnouncementBannerManager( - client as unknown as CoderApi, + const manager = new AnnouncementManager( + client, session, secretsManager, logger, ); + managers.push(manager); return { - config, client, + config, logger, manager, secretsManager, @@ -91,65 +85,35 @@ async function signIn(session: SessionStore): Promise { await flushPromises(); } -describe("normalizeAnnouncementBanners", () => { - it("returns active service and announcement banners", () => { - const banners = normalizeAnnouncementBanners( - appearance({ - service_banner: banner({ message: "Service banner" }), - announcement_banners: [ - banner({ message: "Announcement" }), - banner({ enabled: false, message: "Disabled" }), - banner({ message: " " }), - ], - }), - ); - - expect(banners.map((b) => [b.source, b.message])).toEqual([ - ["service", "Service banner"], - ["announcement", "Announcement"], - ]); - }); - - it("keys ignore order but change when content changes", () => { - const key = getBannerKey({ - source: "announcement", - message: "Maintenance tonight", - backgroundColor: "#004852", - }); +function nextAppearance( + client: MockAppearanceClient, + messages: readonly string[], +): void { + client.getAppearance.mockResolvedValueOnce(appearance(messages)); +} - expect( - getBannerKey({ - source: "announcement", - message: "Maintenance tonight", - backgroundColor: "#004852", - }), - ).toBe(key); - expect( - getBannerKey({ - source: "announcement", - message: "Maintenance tomorrow", - backgroundColor: "#004852", - }), - ).not.toBe(key); - }); -}); +function expectInfo(message: string, ...items: string[]): void { + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + message, + ...items, + ); +} -describe("AnnouncementBannerManager", () => { +describe("AnnouncementManager", () => { beforeEach(() => { vi.useRealTimers(); vi.clearAllMocks(); }); + afterEach(() => { + while (managers.length > 0) { + managers.pop()?.dispose(); + } + }); + it("shows all active banners in the status bar", async () => { const { client, session, statusBar } = setup(); - client.getAppearance.mockResolvedValueOnce( - appearance({ - announcement_banners: [ - banner({ message: "First" }), - banner({ message: "Second" }), - ], - }), - ); + nextAppearance(client, ["First", "Second"]); await signIn(session); @@ -161,36 +125,15 @@ describe("AnnouncementBannerManager", () => { it("notifies only newly seen banners", async () => { const { client, manager, secretsManager, session } = setup(); - client.getAppearance.mockResolvedValueOnce( - appearance({ - announcement_banners: [ - banner({ message: "First" }), - banner({ message: "Second" }), - ], - }), - ); + nextAppearance(client, ["First", "Second"]); await signIn(session); - expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( - "Coder has 2 new deployment announcements.", - "View", - ); + expectInfo("Coder has 2 new deployment announcements.", "View"); vi.mocked(vscode.window.showInformationMessage).mockClear(); - client.getAppearance.mockResolvedValueOnce( - appearance({ - announcement_banners: [ - banner({ message: "First" }), - banner({ message: "Second" }), - banner({ message: "Third" }), - ], - }), - ); + nextAppearance(client, ["First", "Second", "Third"]); await manager.refresh({ notify: true }); - expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( - "Coder announcement: Third", - "View", - ); + expectInfo("Coder announcement: Third", "View"); expect(secretsManager.getSeenBanners(DEPLOYMENT.safeHostname)).toHaveLength( 3, ); @@ -198,9 +141,7 @@ describe("AnnouncementBannerManager", () => { it("does not notify for banners already seen on the same deployment", async () => { const { client, manager, session } = setup(); - client.getAppearance.mockResolvedValue( - appearance({ announcement_banners: [banner()] }), - ); + client.getAppearance.mockResolvedValue(appearance(["Maintenance tonight"])); await signIn(session); vi.mocked(vscode.window.showInformationMessage).mockClear(); @@ -212,9 +153,7 @@ describe("AnnouncementBannerManager", () => { it("suppresses popups when notifications are disabled but keeps status bar", async () => { const { client, config, session, statusBar } = setup(); config.set("coder.disableNotifications", true); - client.getAppearance.mockResolvedValueOnce( - appearance({ announcement_banners: [banner()] }), - ); + nextAppearance(client, ["Maintenance tonight"]); await signIn(session); @@ -225,12 +164,9 @@ describe("AnnouncementBannerManager", () => { it("shows newly seen banners on a different deployment", async () => { const { client, session } = setup(); - client.getAppearance.mockResolvedValue( - appearance({ announcement_banners: [banner()] }), - ); - + client.getAppearance.mockResolvedValue(appearance(["Maintenance tonight"])); session.signIn(DEPLOYMENT, createMockUser()); - await Promise.resolve(); + await flushPromises(); vi.mocked(vscode.window.showInformationMessage).mockClear(); session.signIn( @@ -239,19 +175,12 @@ describe("AnnouncementBannerManager", () => { ); await flushPromises(); - expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( - "Coder announcement: Maintenance tonight", - "View", - ); + expectInfo("Coder announcement: Maintenance tonight", "View"); }); it("refreshes before showing announcements from the command", async () => { const { client, manager, session } = setup(); - client.getAppearance.mockResolvedValue( - appearance({ - announcement_banners: [banner({ message: "Full details" })], - }), - ); + client.getAppearance.mockResolvedValue(appearance(["Full details"])); vi.mocked(vscode.window.showQuickPick).mockResolvedValueOnce({ label: "$(megaphone) Announcement 1", detail: "Full details", @@ -271,20 +200,81 @@ describe("AnnouncementBannerManager", () => { [expect.objectContaining({ detail: "Full details" })], expect.objectContaining({ title: "Coder Announcements" }), ); - expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( - "Full details", + expectInfo("Full details"); + }); + + it("shows an empty message when there are no active announcements", async () => { + const { manager, session } = setup(); + await signIn(session); + vi.mocked(vscode.window.showInformationMessage).mockClear(); + + await manager.showAnnouncements(); + + expectInfo("No active Coder announcements."); + }); + + it("shows refresh errors from the command", async () => { + const { client, logger, manager, session } = setup(); + await signIn(session); + vi.mocked(vscode.window.showInformationMessage).mockClear(); + client.getAppearance.mockRejectedValueOnce(new Error("boom")); + + await manager.showAnnouncements(); + + expect(logger.warn).toHaveBeenCalledWith( + "Failed to refresh Coder announcements", + expect.any(Error), + ); + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Failed to refresh Coder announcements: boom", ); + expectInfo("No active Coder announcements."); }); - it("clears status bar when signed out", async () => { + it("opens the announcements command from the popup action", async () => { + const { client, manager, session } = setup(); + nextAppearance(client, ["Maintenance tonight"]); + const showAnnouncements = vi + .spyOn(manager, "showAnnouncements") + .mockResolvedValue(); + vi.mocked(vscode.window.showInformationMessage).mockResolvedValueOnce( + "View" as never, + ); + + await signIn(session); + await flushPromises(); + + expect(showAnnouncements).toHaveBeenCalledOnce(); + }); + + it("ignores stale refreshes after the session changes", async () => { const { client, session, statusBar } = setup(); - client.getAppearance.mockResolvedValueOnce( - appearance({ announcement_banners: [banner()] }), + const stale = Promise.withResolvers(); + client.getAppearance + .mockReturnValueOnce(stale.promise) + .mockResolvedValueOnce(appearance(["Current"])); + + session.signIn(DEPLOYMENT, createMockUser()); + await Promise.resolve(); + session.signIn( + { url: "https://other.example.com", safeHostname: "other.example.com" }, + createMockUser(), ); + stale.resolve(appearance(["Stale"])); + await flushPromises(); + + expect(statusBar.tooltip).toContain("Current"); + expect(statusBar.tooltip).not.toContain("Stale"); + }); + + it("clears status bar when signed out", async () => { + const { client, session, statusBar } = setup(); + nextAppearance(client, ["Maintenance tonight"]); await signIn(session); + statusBar.hide.mockClear(); session.signOut(null); - expect(statusBar.hide).toHaveBeenCalled(); + expect(statusBar.hide).toHaveBeenCalledOnce(); }); }); From 9bca5507ae228bd414c5ff2ca1fec315c946e2cb Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 30 Jun 2026 10:59:51 +0000 Subject: [PATCH 3/3] refactor: simplify announcement banner code --- src/announcements/banners.ts | 8 ---- src/announcements/manager.ts | 51 ++++++++++--------------- test/unit/announcements/banners.test.ts | 44 +++++++-------------- test/unit/announcements/manager.test.ts | 49 +++++++++++------------- 4 files changed, 58 insertions(+), 94 deletions(-) diff --git a/src/announcements/banners.ts b/src/announcements/banners.ts index a46b1bb570..745f89f1e4 100644 --- a/src/announcements/banners.ts +++ b/src/announcements/banners.ts @@ -62,14 +62,6 @@ export function popupMessage(banners: readonly Announcement[]): string { : `Coder has ${banners.length} new deployment announcements.`; } -export function sourceLabel(source: AnnouncementSource): string { - return source === "service" ? "Service banner" : "Announcement"; -} - -export function sourceIcon(source: AnnouncementSource): string { - return source === "service" ? "$(info)" : "$(megaphone)"; -} - function toAnnouncement( source: AnnouncementSource, banner: BannerConfig, diff --git a/src/announcements/manager.ts b/src/announcements/manager.ts index bfd9ac0a9f..5c81997a08 100644 --- a/src/announcements/manager.ts +++ b/src/announcements/manager.ts @@ -2,10 +2,7 @@ import * as vscode from "vscode"; import { type CoderApi } from "../api/coderApi"; import { type SecretsManager } from "../core/secretsManager"; -import { - type SessionData, - type SessionState, -} from "../deployment/sessionStore"; +import { type SessionState } from "../deployment/sessionStore"; import { type Logger } from "../logging/logger"; import { areNotificationsDisabled } from "../settings/notifications"; @@ -13,8 +10,6 @@ import { type Announcement, normalizeBanners, popupMessage, - sourceIcon, - sourceLabel, statusText, statusTooltip, } from "./banners"; @@ -93,7 +88,7 @@ export class AnnouncementManager implements vscode.Disposable { const selected = await vscode.window.showQuickPick( banners.map((banner, index) => ({ - label: `${sourceIcon(banner.source)} ${sourceLabel(banner.source)} ${index + 1}`, + label: `${banner.source === "service" ? "$(info) Service banner" : "$(megaphone) Announcement"} ${index + 1}`, detail: banner.message, description: banner.backgroundColor, banner, @@ -126,7 +121,7 @@ export class AnnouncementManager implements vscode.Disposable { } const banners = normalizeBanners(await this.client.getAppearance()); - if (this.sessionChangedSince(session)) { + if (this.disposed || this.sessionState.current !== session) { return undefined; } this.setBanners(banners); @@ -135,8 +130,12 @@ export class AnnouncementManager implements vscode.Disposable { this.secretsManager.getSeenBanners(session.deployment.safeHostname), ); const unseen = banners.filter((banner) => !seen.has(banner.key)); - if (options.notify && unseen.length > 0 && notificationsEnabled()) { - this.showPopup(unseen); + if ( + options.notify && + unseen.length > 0 && + !areNotificationsDisabled(vscode.workspace.getConfiguration()) + ) { + void this.showPopup(unseen); } await this.secretsManager.setSeenBanners( session.deployment.safeHostname, @@ -156,18 +155,18 @@ export class AnnouncementManager implements vscode.Disposable { this.statusBarItem.show(); } - private showPopup(banners: readonly Announcement[]): void { - void Promise.resolve( - vscode.window.showInformationMessage(popupMessage(banners), VIEW_ACTION), - ) - .then((action) => { - if (action === VIEW_ACTION) { - void this.showAnnouncements(); - } - }) - .catch((error: unknown) => { - this.logger.warn("Failed to show Coder announcement popup", error); - }); + private async showPopup(banners: readonly Announcement[]): Promise { + try { + const action = await vscode.window.showInformationMessage( + popupMessage(banners), + VIEW_ACTION, + ); + if (action === VIEW_ACTION) { + void this.showAnnouncements(); + } + } catch (error) { + this.logger.warn("Failed to show Coder announcement popup", error); + } } private scheduleRefresh(): void { @@ -190,14 +189,6 @@ export class AnnouncementManager implements vscode.Disposable { this.refreshTimeout = undefined; } } - - private sessionChangedSince(session: SessionData): boolean { - return this.disposed || this.sessionState.current !== session; - } -} - -function notificationsEnabled(): boolean { - return !areNotificationsDisabled(vscode.workspace.getConfiguration()); } function errorMessage(error: unknown): string { diff --git a/test/unit/announcements/banners.test.ts b/test/unit/announcements/banners.test.ts index 9edca9420e..67848e9ac9 100644 --- a/test/unit/announcements/banners.test.ts +++ b/test/unit/announcements/banners.test.ts @@ -4,8 +4,6 @@ import { bannerKey, normalizeBanners, popupMessage, - sourceIcon, - sourceLabel, statusText, statusTooltip, } from "@/announcements/banners"; @@ -100,27 +98,20 @@ describe("normalizeBanners", () => { backgroundColor: "#004852", }); - expect( - bannerKey({ - source: "service", - message: "Maintenance tonight", - backgroundColor: "#004852", - }), - ).not.toBe(key); - expect( - bannerKey({ - source: "announcement", - message: "Maintenance tomorrow", - backgroundColor: "#004852", - }), - ).not.toBe(key); - expect( - bannerKey({ - source: "announcement", - message: "Maintenance tonight", - backgroundColor: "#111111", - }), - ).not.toBe(key); + for (const changed of [ + { source: "service" as const }, + { message: "Maintenance tomorrow" }, + { backgroundColor: "#111111" }, + ]) { + expect( + bannerKey({ + source: "announcement", + message: "Maintenance tonight", + backgroundColor: "#004852", + ...changed, + }), + ).not.toBe(key); + } }); }); @@ -148,11 +139,4 @@ describe("banner copy", () => { `Coder announcement: ${"a".repeat(119)}…`, ); }); - - it("formats QuickPick source labels", () => { - expect(sourceIcon("service")).toBe("$(info)"); - expect(sourceIcon("announcement")).toBe("$(megaphone)"); - expect(sourceLabel("service")).toBe("Service banner"); - expect(sourceLabel("announcement")).toBe("Announcement"); - }); }); diff --git a/test/unit/announcements/manager.test.ts b/test/unit/announcements/manager.test.ts index dbdbb853f8..5e568001d5 100644 --- a/test/unit/announcements/manager.test.ts +++ b/test/unit/announcements/manager.test.ts @@ -15,30 +15,20 @@ import { MockStatusBarItem, } from "../../mocks/testHelpers"; -import type { - AppearanceConfig, - BannerConfig, -} from "coder/site/src/api/typesGenerated"; +import type { AppearanceConfig } from "coder/site/src/api/typesGenerated"; const DEPLOYMENT = { url: "https://coder.example.com", safeHostname: "coder.example.com", }; -class MockAppearanceClient { - readonly getAppearance = vi.fn<() => Promise>(); +function createClient() { + return { getAppearance: vi.fn<() => Promise>() }; } -const managers: AnnouncementManager[] = []; +type MockAppearanceClient = ReturnType; -function banner(overrides: Partial = {}): BannerConfig { - return { - enabled: true, - message: "Maintenance tonight", - background_color: "#004852", - ...overrides, - }; -} +let manager: AnnouncementManager | undefined; function appearance(messages: readonly string[] = []): AppearanceConfig { return { @@ -46,13 +36,17 @@ function appearance(messages: readonly string[] = []): AppearanceConfig { logo_url: "", docs_url: "", service_banner: { enabled: false }, - announcement_banners: messages.map((message) => banner({ message })), + announcement_banners: messages.map((message) => ({ + enabled: true, + message, + background_color: "#004852", + })), }; } function setup() { const config = new MockConfigurationProvider(); - const client = new MockAppearanceClient(); + const client = createClient(); client.getAppearance.mockResolvedValue(appearance()); const session = new SessionStore(); const secretsManager = new SecretsManager( @@ -62,18 +56,18 @@ function setup() { ); const statusBar = new MockStatusBarItem(); const logger = createMockLogger(); - const manager = new AnnouncementManager( + const announcementManager = new AnnouncementManager( client, session, secretsManager, logger, ); - managers.push(manager); + manager = announcementManager; return { client, config, logger, - manager, + manager: announcementManager, secretsManager, session, statusBar, @@ -106,9 +100,8 @@ describe("AnnouncementManager", () => { }); afterEach(() => { - while (managers.length > 0) { - managers.pop()?.dispose(); - } + manager?.dispose(); + manager = undefined; }); it("shows all active banners in the status bar", async () => { @@ -165,8 +158,7 @@ describe("AnnouncementManager", () => { it("shows newly seen banners on a different deployment", async () => { const { client, session } = setup(); client.getAppearance.mockResolvedValue(appearance(["Maintenance tonight"])); - session.signIn(DEPLOYMENT, createMockUser()); - await flushPromises(); + await signIn(session); vi.mocked(vscode.window.showInformationMessage).mockClear(); session.signIn( @@ -197,7 +189,12 @@ describe("AnnouncementManager", () => { await manager.showAnnouncements(); expect(vscode.window.showQuickPick).toHaveBeenCalledWith( - [expect.objectContaining({ detail: "Full details" })], + [ + expect.objectContaining({ + label: "$(megaphone) Announcement 1", + detail: "Full details", + }), + ], expect.objectContaining({ title: "Coder Announcements" }), ); expectInfo("Full details");