diff --git a/CHANGELOG.md b/CHANGELOG.md index b10063021..dc5a7df93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ ## Unreleased +### Added + +- Show active deployment announcements in a megaphone status bar item, with a + popup for announcements you haven't seen yet (respecting + `coder.disableNotifications`; suppressed announcements highlight the status + bar item instead). A new **Coder: View Announcements** command opens the full + messages in a markdown preview. + ### Fixed - Apply a 60-second default timeout to REST requests, so requests hung on a diff --git a/package.json b/package.json index 87757a4f5..d8c4eb4ef 100644 --- a/package.json +++ b/package.json @@ -460,6 +460,12 @@ "title": "Coder: Export Telemetry", "icon": "$(save)" }, + { + "command": "coder.viewAnnouncements", + "title": "View Announcements", + "category": "Coder", + "icon": "$(megaphone)" + }, { "command": "coder.openAppStatus", "title": "Open App Status", @@ -588,6 +594,10 @@ "command": "coder.exportTelemetry", "when": "true" }, + { + "command": "coder.viewAnnouncements", + "when": "coder.authenticated" + }, { "command": "coder.openAppStatus", "when": "false" diff --git a/src/announcements/banners.ts b/src/announcements/banners.ts new file mode 100644 index 000000000..066f35098 --- /dev/null +++ b/src/announcements/banners.ts @@ -0,0 +1,122 @@ +import { createHash } from "node:crypto"; + +import type { + AppearanceConfig, + BannerConfig, +} from "coder/site/src/api/typesGenerated"; + +export interface Announcement { + readonly message: string; + /** Fingerprint used to track which banners have been surfaced. */ + readonly key: string; + /** Admin-configured hex color (e.g. "#d32f2f"), if set and valid. */ + readonly backgroundColor?: string; +} + +/** + * Falls back to the deprecated service_banner only when announcement_banners + * is absent, not merely empty. Duplicate messages collapse into one. + */ +export function normalizeBanners( + appearance: AppearanceConfig, +): readonly Announcement[] { + const banners = appearance.announcement_banners ?? [ + appearance.service_banner, + ]; + const announcements = banners + .map((banner) => toAnnouncement(banner)) + .filter((banner): banner is Announcement => banner !== undefined); + return [...new Map(announcements.map((a) => [a.key, a])).values()]; +} + +/** Status bar label: a megaphone plus the count when there's more than one. */ +export function statusText(count: number): string { + return `$(megaphone) Coder${count === 1 ? "" : ` ${count}`}`; +} + +/** Hover is a quick glance; cap the list before pointing at the full preview. */ +const HOVER_BANNER_LIMIT = 5; + +/** Hard-breaks lines in one paragraph instead of a list: no indent, no implied order. */ +export function hoverMarkdown(banners: readonly Announcement[]): string { + const shown = banners.slice(0, HOVER_BANNER_LIMIT); + const remaining = banners.length - shown.length; + const list = shown.map((banner) => banner.message).join(" \n"); + return remaining > 0 + ? `${list}\n\n+${remaining} more (click to view all)` + : list; +} + +/** Full markdown for the preview tab: each banner boxed in its own styled div. */ +export function previewMarkdown(banners: readonly Announcement[]): string { + const boxes = banners.map((banner) => announcementBox(banner)); + return [BOX_STYLE_RESET, ...boxes].join("\n\n"); +} + +/** + *

only gets bottom margin, skewing the box's padding. Custom-colored + * boxes also need links to inherit the text color instead of theme-blue. + */ +const BOX_STYLE_RESET = + ""; + +/** Reuses the blockquote theme colors so the box still reads as native VS Code. */ +const BOX_STYLE = + "padding: 10px 14px; margin-bottom: 8px; border-radius: 4px; " + + "background: var(--vscode-textBlockQuote-background); " + + "border-left: 4px solid var(--vscode-textBlockQuote-border);"; + +/** A blank line around the content keeps it as real markdown, not opaque HTML. */ +function announcementBox(banner: Announcement): string { + // Admin colors stay fixed across themes, like coder/coder's dashboard. + const className = banner.backgroundColor + ? "coder-announcement coder-announcement-custom-color" + : "coder-announcement"; + const colorOverride = banner.backgroundColor + ? `background: ${banner.backgroundColor}; border-left-color: ${banner.backgroundColor}; color: ${readableForegroundColor(banner.backgroundColor)};` + : ""; + return [ + `

`, + "", + banner.message, + "", + "
", + ].join("\n"); +} + +/** Mirrors coder/coder's dashboard heuristic so banner colors read the same way. */ +function readableForegroundColor(hexColor: string): string { + const r = parseInt(hexColor.slice(1, 3), 16); + const g = parseInt(hexColor.slice(3, 5), 16); + const b = parseInt(hexColor.slice(5, 7), 16); + const yiq = (r * 299 + g * 587 + b * 114) / 1000; + return yiq >= 128 ? "#000" : "#fff"; +} + +/** Plain text, no markdown, so it's always a generic count, never banner content. */ +export function popupMessage(banners: readonly Announcement[]): string { + return banners.length === 1 + ? "Coder has a new deployment announcement." + : `Coder has ${banners.length} new deployment announcements.`; +} + +const HEX_COLOR = /^#[0-9a-f]{6}$/i; + +function toAnnouncement( + banner: BannerConfig | undefined, +): Announcement | undefined { + const message = banner?.message?.trim(); + if (!banner?.enabled || !message) { + return undefined; + } + const backgroundColor = + banner.background_color && HEX_COLOR.test(banner.background_color) + ? banner.background_color + : undefined; + return { message, key: bannerKey(message), backgroundColor }; +} + +function bannerKey(message: string): string { + return createHash("sha256").update(message).digest("hex").slice(0, 16); +} diff --git a/src/announcements/manager.ts b/src/announcements/manager.ts new file mode 100644 index 000000000..08047b7e3 --- /dev/null +++ b/src/announcements/manager.ts @@ -0,0 +1,283 @@ +import * as vscode from "vscode"; + +import { errToStr } from "../api/api-helper"; +import { withProgress } from "../progress"; +import { areNotificationsDisabled } from "../settings/notifications"; +import { createStatusBarItem } from "../util/statusBar"; + +import { + type Announcement, + hoverMarkdown, + normalizeBanners, + popupMessage, + previewMarkdown, + statusText, +} from "./banners"; +import { AnnouncementsPreview } from "./preview"; + +import type { CoderApi } from "../api/coderApi"; +import type { SecretsManager } from "../core/secretsManager"; +import type { SessionState } from "../deployment/sessionStore"; +import type { Logger } from "../logging/logger"; + +/** Background poll interval; sign-in and manual refresh happen immediately either way. */ +export const REFRESH_INTERVAL_MS = 30 * 60 * 1000; +const VIEW_ACTION = "View"; + +interface RefreshOptions { + readonly notify?: boolean; + readonly showErrors?: boolean; +} + +/** Fetches, tracks, and surfaces Coder deployment announcement banners. */ +export class AnnouncementManager implements vscode.Disposable { + private readonly statusBarItem = createStatusBarItem("announcements"); + private readonly sessionChangeDisposable: vscode.Disposable; + private readonly preview = new AnnouncementsPreview(); + #banners: readonly Announcement[] = []; + private fetchGeneration = 0; + private refreshTimeout: NodeJS.Timeout | undefined; + private disposed = false; + private loadingAnnouncements: Promise | undefined; + + public constructor( + private readonly client: Pick, + private readonly sessionState: SessionState, + private readonly secretsManager: SecretsManager, + private readonly logger: Logger, + ) { + this.statusBarItem.command = "coder.viewAnnouncements"; + this.sessionChangeDisposable = this.sessionState.onDidChange(() => { + this.onSessionChange(); + }); + this.onSessionChange(); + } + + /** Refreshes the banners; resolves false when the refresh failed. */ + public async refresh(options: RefreshOptions = {}): Promise { + if (this.disposed) { + return false; + } + this.cancelRefresh(); + try { + await this.fetch(options); + return true; + } catch (error) { + this.logger.warn("Failed to refresh Coder announcements", error); + if (options.showErrors) { + void vscode.window.showErrorMessage( + `Failed to refresh Coder announcements: ${errToStr(error)}`, + ); + } + return false; + } finally { + this.scheduleRefresh(); + } + } + + /** Concurrent calls share one in-flight load, shown via a window progress indicator. */ + public showAnnouncements(): Promise { + this.loadingAnnouncements ??= this.loadAnnouncements().finally(() => { + this.loadingAnnouncements = undefined; + }); + return this.loadingAnnouncements; + } + + private async loadAnnouncements(): Promise { + await withProgress( + { + location: vscode.ProgressLocation.Window, + title: "Loading Coder announcements…", + }, + async () => { + const refreshed = await this.refresh({ + notify: false, + showErrors: true, + }); + if (this.banners.length > 0) { + await this.showAnnouncementsPreview(); + } else if (refreshed) { + // On failure the error popup is the whole story. + void vscode.window.showInformationMessage( + "No active Coder announcements.", + ); + } + }, + ); + } + + /** Releases listeners/timers and hides the status bar item. */ + public dispose(): void { + this.disposed = true; + this.cancelRefresh(); + this.sessionChangeDisposable.dispose(); + this.preview.dispose(); + this.banners = []; + this.statusBarItem.dispose(); + } + + /** Re-fetches on sign-in; clears the display on sign-out. */ + private onSessionChange(): void { + this.cancelRefresh(); + if (this.sessionState.current.kind !== "signedIn") { + this.banners = []; + return; + } + // Leaves current banners visible while refresh() below confirms them. + void this.refresh({ notify: true }); + } + + /** Fetches the latest banners and reconciles popup/surfaced/attention state. */ + private async fetch(options: RefreshOptions): Promise { + const session = this.sessionState.current; + if (session.kind !== "signedIn") { + this.banners = []; + return; + } + + const generation = ++this.fetchGeneration; + const appearance = await this.client.getAppearance(); + // A newer fetch or a session change supersedes this response. + if ( + this.disposed || + generation !== this.fetchGeneration || + this.sessionState.current !== session + ) { + return; + } + const banners = normalizeBanners(appearance); + this.banners = banners; + + const surfacedKeys = new Set( + this.secretsManager.getSurfacedBanners(session.deployment.safeHostname), + ); + const newBanners = banners.filter( + (banner) => !surfacedKeys.has(banner.key), + ); + if (newBanners.length === 0) { + this.setAttentionIndicator(false); + return; + } + // Manual refreshes surface via the preview instead. + if (!options.notify) { + return; + } + if (areNotificationsDisabled(vscode.workspace.getConfiguration())) { + // Marks banners as surfaced, not read; suppressed ones notify later. + this.setAttentionIndicator(true); + return; + } + this.showPopup(newBanners); + await this.markSurfaced(banners, surfacedKeys); + } + + /** Marks banners surfaced and clears the attention color; logs storage failures. */ + private async markSurfaced( + banners: readonly Announcement[], + knownSurfacedKeys?: ReadonlySet, + ): Promise { + const session = this.sessionState.current; + if (session.kind !== "signedIn") { + return; + } + try { + const surfacedKeys = + knownSurfacedKeys ?? + new Set( + this.secretsManager.getSurfacedBanners( + session.deployment.safeHostname, + ), + ); + const merged = new Set([ + ...surfacedKeys, + ...banners.map((banner) => banner.key), + ]); + if (merged.size > surfacedKeys.size) { + await this.secretsManager.setSurfacedBanners( + session.deployment.safeHostname, + [...merged], + ); + } + this.setAttentionIndicator(false); + } catch (error) { + this.logger.warn("Failed to mark Coder announcements as surfaced", error); + } + } + + /** Toggles a warning background so unsurfaced banners stand out in the status bar. */ + private setAttentionIndicator(hasUnsurfaced: boolean): void { + this.statusBarItem.backgroundColor = hasUnsurfaced + ? new vscode.ThemeColor("statusBarItem.warningBackground") + : undefined; + } + + /** The setter syncs the status bar so the two cannot drift apart. */ + private get banners(): readonly Announcement[] { + return this.#banners; + } + + private set banners(banners: readonly Announcement[]) { + this.#banners = banners; + if (banners.length === 0) { + this.statusBarItem.hide(); + return; + } + this.statusBarItem.text = statusText(banners.length); + this.statusBarItem.tooltip = new vscode.MarkdownString( + hoverMarkdown(banners), + ); + this.statusBarItem.show(); + } + + /** Non-modal messages may never settle, so chain instead of awaiting. */ + private showPopup(banners: readonly Announcement[]): void { + vscode.window + .showInformationMessage(popupMessage(banners), VIEW_ACTION) + .then((action) => { + if (action === VIEW_ACTION) { + // Banners are seconds old; skip the refetch. + void this.showAnnouncementsPreview(); + } + }); + } + + /** Skips the preview if banners were cleared (e.g. sign-out) before or during marking. */ + private async showAnnouncementsPreview(): Promise { + if (this.banners.length === 0) { + return; + } + await this.markSurfaced(this.banners); + if (this.banners.length === 0) { + return; + } + try { + await this.preview.show(previewMarkdown(this.banners)); + } catch (error) { + this.logger.warn("Failed to show Coder announcements preview", error); + void vscode.window.showErrorMessage( + `Failed to show Coder announcements: ${errToStr(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; + } + } +} diff --git a/src/announcements/preview.ts b/src/announcements/preview.ts new file mode 100644 index 000000000..f0e89d923 --- /dev/null +++ b/src/announcements/preview.ts @@ -0,0 +1,28 @@ +import * as vscode from "vscode"; + +const SCHEME = "coder-announcements"; +// No .md extension: the preview renders content as markdown regardless. +const URI = vscode.Uri.parse(`${SCHEME}:Coder Announcements`); + +/** Shows deployment announcements in VS Code's built-in Markdown Preview. */ +export class AnnouncementsPreview implements vscode.Disposable { + private markdown = ""; + private readonly changeEmitter = new vscode.EventEmitter(); + private readonly providerDisposable = + vscode.workspace.registerTextDocumentContentProvider(SCHEME, { + onDidChange: this.changeEmitter.event, + provideTextDocumentContent: () => this.markdown, + }); + + /** Opens (or refreshes, if already open) the preview tab with new content. */ + public async show(markdown: string): Promise { + this.markdown = markdown; + this.changeEmitter.fire(URI); + await vscode.commands.executeCommand("markdown.showPreview", URI); + } + + public dispose(): void { + this.changeEmitter.dispose(); + this.providerDisposable.dispose(); + } +} diff --git a/src/core/commandManager.ts b/src/core/commandManager.ts index 723a4fe21..c9f721c3c 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 5255c122c..b5cbc7419 100644 --- a/src/core/secretsManager.ts +++ b/src/core/secretsManager.ts @@ -13,6 +13,7 @@ import type { Logger } from "../logging/logger"; const SESSION_KEY_PREFIX = "coder.session."; const OAUTH_CLIENT_PREFIX = "coder.oauth.client."; const DEPLOYMENT_ACCESS_PREFIX = "coder.access."; +const SURFACED_BANNERS_PREFIX = "coder.surfacedBanners."; type SecretKeyPrefix = typeof SESSION_KEY_PREFIX | typeof OAUTH_CLIENT_PREFIX; @@ -29,6 +30,8 @@ export type CurrentDeploymentState = z.infer< typeof CurrentDeploymentStateSchema >; +const SurfacedBannersSchema = z.array(z.string()); + /** * OAuth token data stored alongside session auth. * When present, indicates the session is authenticated via OAuth. @@ -235,6 +238,7 @@ export class SecretsManager { await Promise.all([ this.clearSessionAuth(safeHostname), this.clearOAuthClientRegistration(safeHostname), + this.clearSurfacedBanners(safeHostname), this.memento.update( `${DEPLOYMENT_ACCESS_PREFIX}${safeHostname}`, undefined, @@ -242,6 +246,30 @@ export class SecretsManager { ]); } + public getSurfacedBanners(safeHostname: string): string[] { + const raw = this.memento.get( + `${SURFACED_BANNERS_PREFIX}${safeHostname}`, + ); + const result = SurfacedBannersSchema.safeParse(raw); + return result.success ? result.data : []; + } + + public async setSurfacedBanners( + safeHostname: string, + bannerKeys: readonly string[], + ): Promise { + await this.memento.update(`${SURFACED_BANNERS_PREFIX}${safeHostname}`, [ + ...bannerKeys, + ]); + } + + private async clearSurfacedBanners(safeHostname: string): Promise { + await this.memento.update( + `${SURFACED_BANNERS_PREFIX}${safeHostname}`, + undefined, + ); + } + /** * 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 e097daa87..541331562 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 { AnnouncementManager } from "./announcements/manager"; 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 announcementManager = new AnnouncementManager( + client, + deploymentManager.session, + secretsManager, + output, + ); + ctx.subscriptions.push(announcementManager); + const myWorkspacesProvider = new WorkspaceProvider( WorkspaceQuery.Mine, client, @@ -328,6 +337,10 @@ async function doActivate( "coder.exportTelemetry", commands.exportTelemetry.bind(commands), ); + commandManager.register( + "coder.viewAnnouncements", + announcementManager.showAnnouncements.bind(announcementManager), + ); commandManager.register("coder.searchMyWorkspaces", async () => showTreeViewSearch(MY_WORKSPACES_TREE_ID), ); diff --git a/src/remote/remote.ts b/src/remote/remote.ts index 14f7b4609..b08bc8d9f 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -43,6 +43,7 @@ import { type AuthorityParts, parseRemoteAuthority, } from "../util/authority"; +import { createStatusBarItem } from "../util/statusBar"; import { vscodeProposed } from "../vscodeProposed"; import { WorkspaceMonitor } from "../workspace/workspaceMonitor"; @@ -1080,10 +1081,7 @@ export class Remote { agent: WorkspaceAgent, client: CoderApi, ): Promise { - const statusBarItem = vscode.window.createStatusBarItem( - "agentMetadata", - vscode.StatusBarAlignment.Left, - ); + const statusBarItem = createStatusBarItem("agentMetadata"); const agentWatcher = await createAgentMetadataWatcher(agent.id, client); diff --git a/src/remote/sshProcess.ts b/src/remote/sshProcess.ts index 58d0fee49..3b4818996 100644 --- a/src/remote/sshProcess.ts +++ b/src/remote/sshProcess.ts @@ -6,6 +6,7 @@ import * as vscode from "vscode"; import { SshTelemetry, type ProcessLossCause } from "../instrumentation/ssh"; import { findPort } from "../util"; import { cleanupFiles } from "../util/fileCleanup"; +import { createStatusBarItem } from "../util/statusBar"; import { NetworkStatusReporter } from "./networkStatus"; import { @@ -140,10 +141,7 @@ export class SshProcessMonitor implements vscode.Disposable { networkPollInterval: options.networkPollInterval ?? 3000, }; this.telemetry = new SshTelemetry(options.telemetry); - this.statusBarItem = vscode.window.createStatusBarItem( - vscode.StatusBarAlignment.Left, - 1000, - ); + this.statusBarItem = createStatusBarItem("networkStatus"); this.reporter = new NetworkStatusReporter(this.statusBarItem); } diff --git a/src/util/statusBar.ts b/src/util/statusBar.ts new file mode 100644 index 000000000..c39d99339 --- /dev/null +++ b/src/util/statusBar.ts @@ -0,0 +1,44 @@ +import * as vscode from "vscode"; + +interface StatusBarItemSpec { + /** Omitted for pre-registry items; a new id orphans hide preferences. */ + readonly id?: string; + readonly name: string; + readonly priority: number; +} + +/** All Coder status bar items; higher priority renders further left. */ +const STATUS_BAR_ITEMS = { + networkStatus: { name: "Coder Network Status", priority: 1000 }, + workspaceUpdate: { name: "Coder Workspace Update", priority: 999 }, + announcements: { + id: "announcements", + name: "Coder Announcements", + priority: 998, + }, + // Priority 0 preserves its pre-registry placement. + agentMetadata: { + id: "agentMetadata", + name: "Coder Agent Metadata", + priority: 0, + }, +} satisfies Record; + +/** Creates a registered status bar item with its id, name, and priority. */ +export function createStatusBarItem( + key: keyof typeof STATUS_BAR_ITEMS, +): vscode.StatusBarItem { + const { id, name, priority }: StatusBarItemSpec = STATUS_BAR_ITEMS[key]; + const item = id + ? vscode.window.createStatusBarItem( + id, + vscode.StatusBarAlignment.Left, + priority, + ) + : vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Left, + priority, + ); + item.name = name; + return item; +} diff --git a/src/workspace/workspaceMonitor.ts b/src/workspace/workspaceMonitor.ts index 150fac1e9..ff6d23da4 100644 --- a/src/workspace/workspaceMonitor.ts +++ b/src/workspace/workspaceMonitor.ts @@ -11,6 +11,7 @@ import { areNotificationsDisabled, areUpdateNotificationsDisabled, } from "../settings/notifications"; +import { createStatusBarItem } from "../util/statusBar"; import { vscodeProposed } from "../vscodeProposed"; import type { CoderApi } from "../api/coderApi"; @@ -64,11 +65,7 @@ export class WorkspaceMonitor implements vscode.Disposable { ); this.latestWorkspace = workspace; - const statusBarItem = vscode.window.createStatusBarItem( - vscode.StatusBarAlignment.Left, - 999, - ); - statusBarItem.name = "Coder Workspace Update"; + const statusBarItem = createStatusBarItem("workspaceUpdate"); statusBarItem.text = "$(fold-up) Update Workspace"; statusBarItem.command = "coder.workspace.update"; diff --git a/test/mocks/vscode.runtime.ts b/test/mocks/vscode.runtime.ts index 03dc548d2..55cd482e7 100644 --- a/test/mocks/vscode.runtime.ts +++ b/test/mocks/vscode.runtime.ts @@ -148,9 +148,10 @@ export const window = { window.activeColorTheme = { kind }; onDidChangeActiveColorTheme.fire({ kind }); }, - showInformationMessage: vi.fn(), - showWarningMessage: vi.fn(), - showErrorMessage: vi.fn(), + // The real API always returns a Thenable, so callers may chain .then(). + showInformationMessage: vi.fn(() => Promise.resolve(undefined)), + showWarningMessage: vi.fn(() => Promise.resolve(undefined)), + showErrorMessage: vi.fn(() => Promise.resolve(undefined)), showQuickPick: vi.fn(), showInputBox: vi.fn(), showSaveDialog: vi.fn(), @@ -187,6 +188,7 @@ export const workspace = { readDirectory: vi.fn(), }, openTextDocument: vi.fn(), + registerTextDocumentContentProvider: vi.fn(() => ({ dispose: vi.fn() })), onDidChangeConfiguration: onDidChangeConfiguration.event, onDidChangeWorkspaceFolders: onDidChangeWorkspaceFolders.event, diff --git a/test/unit/announcements/banners.test.ts b/test/unit/announcements/banners.test.ts new file mode 100644 index 000000000..a09e885ce --- /dev/null +++ b/test/unit/announcements/banners.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from "vitest"; + +import { + hoverMarkdown, + normalizeBanners, + popupMessage, + previewMarkdown, + statusText, +} 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 announcement banners trimmed", () => { + const banners = normalizeBanners( + appearance({ + announcement_banners: [ + banner({ message: " First " }), + banner({ enabled: false, message: "Disabled" }), + banner({ message: " " }), + banner({ message: "Second" }), + ], + }), + ); + + expect(banners.map((banner) => banner.message)).toEqual([ + "First", + "Second", + ]); + }); + + it("ignores the service banner that modern deployments mirror from the first announcement", () => { + const banners = normalizeBanners( + appearance({ + service_banner: banner(), + announcement_banners: [banner(), banner({ message: "Second" })], + }), + ); + + expect(banners.map((banner) => banner.message)).toEqual([ + "Maintenance tonight", + "Second", + ]); + }); + + it("falls back to the service banner on older deployments", () => { + expect(normalizeBanners({} as AppearanceConfig)).toEqual([]); + expect( + normalizeBanners({ service_banner: banner() } as AppearanceConfig), + ).toMatchObject([{ message: "Maintenance tonight" }]); + }); + + it("keys follow the message and survive reordering", () => { + const [first, second] = announcements("First", "Second"); + const [reorderedSecond, reorderedFirst] = announcements("Second", "First"); + + expect(reorderedFirst.key).toBe(first.key); + expect(reorderedSecond.key).toBe(second.key); + expect(first.key).not.toBe(second.key); + }); + + it("collapses banners with identical content", () => { + const banners = announcements("Same message", "Same message"); + + expect(banners).toHaveLength(1); + }); + + it("shows nothing when a modern deployment reports no announcements", () => { + const banners = normalizeBanners( + appearance({ + service_banner: banner(), + announcement_banners: [], + }), + ); + + expect(banners).toEqual([]); + }); +}); + +describe("banner copy", () => { + it("formats status bar text and the hover markdown", () => { + const banners = announcements("First", "Second"); + + expect(statusText(1)).toBe("$(megaphone) Coder"); + expect(statusText(2)).toBe("$(megaphone) Coder 2"); + expect(hoverMarkdown(banners)).toBe("First \nSecond"); + }); + + it("caps the hover list and points at the full preview", () => { + const banners = announcements("A", "B", "C", "D", "E", "F", "G"); + + expect(hoverMarkdown(banners)).toBe( + "A \nB \nC \nD \nE\n\n+2 more (click to view all)", + ); + }); + + it("boxes each announcement in its own styled div, blank-line separated so markdown still renders inside", () => { + const banners = announcements("First"); + + expect(previewMarkdown(banners)).toMatch( + /^