Skip to content

Commit 9c063da

Browse files
committed
refactor: address announcement banner review notes
Centralize status bar item names and priorities in util/statusBar while preserving each item's pre-registry id and placement. Read the deprecated service_banner only when announcement_banners is absent so modern deployments that mirror the first announcement into it show no duplicates, and derive banner keys from the message alone. Union seen keys instead of overwriting, mark banners seen only when the user could see them, and guard overlapping fetches with a generation counter so stale responses never apply. Open the picker from the popup without refetching, show the empty-state message only after a successful refresh, avoid awaiting non-modal notifications, truncate popup text by code points, and align tests with the setup() factory pattern.
1 parent 5b68f35 commit 9c063da

8 files changed

Lines changed: 263 additions & 239 deletions

File tree

src/announcements/banners.ts

Lines changed: 19 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -7,51 +7,31 @@ import type {
77

88
const POPUP_MESSAGE_MAX_LENGTH = 120;
99

10-
export type AnnouncementSource = "announcement" | "service";
11-
1210
export interface Announcement {
13-
readonly source: AnnouncementSource;
1411
readonly message: string;
15-
readonly backgroundColor?: string;
12+
/** Stable fingerprint used to track which banners were already seen. */
1613
readonly key: string;
1714
}
1815

1916
export function normalizeBanners(
2017
appearance: AppearanceConfig,
2118
): readonly Announcement[] {
22-
return [
23-
toAnnouncement("service", appearance.service_banner),
24-
// Nullish guards tolerate older deployments that omit banner fields.
25-
...(appearance.announcement_banners ?? []).map((banner) =>
26-
toAnnouncement("announcement", banner),
27-
),
28-
].filter((banner): banner is Announcement => banner !== undefined);
29-
}
30-
31-
export function bannerKey(
32-
banner: Pick<Announcement, "source" | "message" | "backgroundColor">,
33-
): string {
34-
return createHash("sha256")
35-
.update(
36-
JSON.stringify({
37-
source: banner.source,
38-
message: banner.message,
39-
backgroundColor: banner.backgroundColor ?? "",
40-
}),
41-
)
42-
.digest("hex")
43-
.slice(0, 16);
19+
// Modern servers mirror announcements[0] into the deprecated service_banner.
20+
const banners = appearance.announcement_banners ?? [
21+
appearance.service_banner,
22+
];
23+
return banners
24+
.map((banner) => toAnnouncement(banner))
25+
.filter((banner): banner is Announcement => banner !== undefined);
4426
}
4527

4628
export function statusText(count: number): string {
47-
return count === 1 ? "$(megaphone) Coder" : `$(megaphone) Coder ${count}`;
29+
return `$(megaphone) Coder${count === 1 ? "" : ` ${count}`}`;
4830
}
4931

5032
export function statusTooltip(banners: readonly Announcement[]): string {
5133
return [
52-
banners.length === 1
53-
? "Coder deployment announcement"
54-
: "Coder deployment announcements",
34+
`Coder deployment announcement${banners.length === 1 ? "" : "s"}`,
5535
"",
5636
...banners.map((banner, index) => `${index + 1}. ${banner.message}`),
5737
].join("\n");
@@ -64,24 +44,23 @@ export function popupMessage(banners: readonly Announcement[]): string {
6444
}
6545

6646
function toAnnouncement(
67-
source: AnnouncementSource,
6847
banner: BannerConfig | undefined,
6948
): Announcement | undefined {
7049
const message = banner?.message?.trim();
7150
if (!banner?.enabled || !message) {
7251
return undefined;
7352
}
74-
const backgroundColor = banner.background_color?.trim() || undefined;
75-
return {
76-
source,
77-
message,
78-
backgroundColor,
79-
key: bannerKey({ source, message, backgroundColor }),
80-
};
53+
return { message, key: bannerKey(message) };
54+
}
55+
56+
function bannerKey(message: string): string {
57+
return createHash("sha256").update(message).digest("hex").slice(0, 16);
8158
}
8259

8360
function truncate(message: string): string {
84-
return message.length <= POPUP_MESSAGE_MAX_LENGTH
61+
// Slice code points so emoji are never cut in half.
62+
const chars = [...message];
63+
return chars.length <= POPUP_MESSAGE_MAX_LENGTH
8564
? message
86-
: `${message.slice(0, POPUP_MESSAGE_MAX_LENGTH - 1)}…`;
65+
: `${chars.slice(0, POPUP_MESSAGE_MAX_LENGTH - 1).join("")}…`;
8766
}

src/announcements/manager.ts

Lines changed: 89 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
import * as vscode from "vscode";
22

33
import { errToStr } from "../api/api-helper";
4-
import { type CoderApi } from "../api/coderApi";
5-
import { type SecretsManager } from "../core/secretsManager";
6-
import { type SessionState } from "../deployment/sessionStore";
7-
import { type Logger } from "../logging/logger";
84
import { areNotificationsDisabled } from "../settings/notifications";
5+
import { createStatusBarItem } from "../util/statusBar";
96

107
import {
118
type Announcement,
@@ -15,7 +12,12 @@ import {
1512
statusTooltip,
1613
} from "./banners";
1714

18-
const REFRESH_INTERVAL_MS = 30 * 60 * 1000;
15+
import type { CoderApi } from "../api/coderApi";
16+
import type { SecretsManager } from "../core/secretsManager";
17+
import type { SessionState } from "../deployment/sessionStore";
18+
import type { Logger } from "../logging/logger";
19+
20+
export const REFRESH_INTERVAL_MS = 30 * 60 * 1000;
1921
const VIEW_ACTION = "View";
2022

2123
interface RefreshOptions {
@@ -24,9 +26,10 @@ interface RefreshOptions {
2426
}
2527

2628
export class AnnouncementManager implements vscode.Disposable {
27-
private readonly statusBarItem: vscode.StatusBarItem;
29+
private readonly statusBarItem = createStatusBarItem("announcements");
2830
private readonly sessionChangeDisposable: vscode.Disposable;
29-
private banners: readonly Announcement[] = [];
31+
#banners: readonly Announcement[] = [];
32+
private fetchGeneration = 0;
3033
private refreshTimeout: NodeJS.Timeout | undefined;
3134
private disposed = false;
3235

@@ -36,119 +39,112 @@ export class AnnouncementManager implements vscode.Disposable {
3639
private readonly secretsManager: SecretsManager,
3740
private readonly logger: Logger,
3841
) {
39-
this.statusBarItem = vscode.window.createStatusBarItem(
40-
vscode.StatusBarAlignment.Left,
41-
998,
42-
);
43-
this.statusBarItem.name = "Coder Announcements";
4442
this.statusBarItem.command = "coder.viewAnnouncements";
4543
this.sessionChangeDisposable = this.sessionState.onDidChange(() => {
4644
this.onSessionChange();
4745
});
4846
this.onSessionChange();
4947
}
5048

51-
public dispose(): void {
52-
this.disposed = true;
53-
this.cancelRefresh();
54-
this.sessionChangeDisposable.dispose();
55-
this.statusBarItem.dispose();
56-
}
57-
58-
public async refresh(
59-
options: RefreshOptions = {},
60-
): Promise<readonly Announcement[] | undefined> {
49+
/** Refreshes the banners; resolves false when the refresh failed. */
50+
public async refresh(options: RefreshOptions = {}): Promise<boolean> {
6151
if (this.disposed) {
62-
return undefined;
52+
return false;
6353
}
6454
this.cancelRefresh();
6555
try {
66-
return await this.fetch(options);
56+
await this.fetch(options);
57+
return true;
6758
} catch (error) {
6859
this.logger.warn("Failed to refresh Coder announcements", error);
6960
if (options.showErrors) {
7061
void vscode.window.showErrorMessage(
7162
`Failed to refresh Coder announcements: ${errToStr(error)}`,
7263
);
7364
}
74-
return undefined;
65+
return false;
7566
} finally {
7667
this.scheduleRefresh();
7768
}
7869
}
7970

8071
public async showAnnouncements(): Promise<void> {
81-
const banners =
82-
(await this.refresh({ notify: false, showErrors: true })) ?? this.banners;
83-
if (banners.length === 0) {
72+
const refreshed = await this.refresh({ notify: false, showErrors: true });
73+
if (this.banners.length > 0) {
74+
await this.pickAnnouncement();
75+
} else if (refreshed) {
76+
// On failure the error popup is the whole story.
8477
void vscode.window.showInformationMessage(
8578
"No active Coder announcements.",
8679
);
87-
return;
8880
}
81+
}
8982

90-
const selected = await vscode.window.showQuickPick(
91-
banners.map((banner, index) => ({
92-
label: `${banner.source === "service" ? "$(info) Service banner" : "$(megaphone) Announcement"} ${index + 1}`,
93-
detail: banner.message,
94-
banner,
95-
})),
96-
{
97-
title: "Coder Announcements",
98-
placeHolder: "Select an announcement to view the full message",
99-
},
100-
);
101-
if (selected) {
102-
void vscode.window.showInformationMessage(selected.banner.message);
103-
}
83+
public dispose(): void {
84+
this.disposed = true;
85+
this.cancelRefresh();
86+
this.sessionChangeDisposable.dispose();
87+
this.banners = [];
88+
this.statusBarItem.dispose();
10489
}
10590

10691
private onSessionChange(): void {
10792
this.cancelRefresh();
108-
this.setBanners([]);
93+
this.banners = [];
10994
if (this.sessionState.current.kind === "signedIn") {
11095
void this.refresh({ notify: true });
11196
}
11297
}
11398

114-
private async fetch(
115-
options: RefreshOptions,
116-
): Promise<readonly Announcement[] | undefined> {
99+
private async fetch(options: RefreshOptions): Promise<void> {
117100
const session = this.sessionState.current;
118101
if (session.kind !== "signedIn") {
119-
this.setBanners([]);
120-
return [];
102+
this.banners = [];
103+
return;
121104
}
122105

123-
const banners = normalizeBanners(await this.client.getAppearance());
124-
if (this.disposed || this.sessionState.current !== session) {
125-
return undefined;
106+
const generation = ++this.fetchGeneration;
107+
const appearance = await this.client.getAppearance();
108+
// A newer fetch or a session change supersedes this response.
109+
if (
110+
this.disposed ||
111+
generation !== this.fetchGeneration ||
112+
this.sessionState.current !== session
113+
) {
114+
return;
126115
}
127-
this.setBanners(banners);
116+
const banners = normalizeBanners(appearance);
117+
this.banners = banners;
128118

129119
const seen = new Set(
130120
this.secretsManager.getSeenBanners(session.deployment.safeHostname),
131121
);
132-
const keys = banners.map((banner) => banner.key);
133122
const unseen = banners.filter((banner) => !seen.has(banner.key));
134-
if (
135-
options.notify &&
136-
unseen.length > 0 &&
137-
!areNotificationsDisabled(vscode.workspace.getConfiguration())
138-
) {
139-
void this.showPopup(unseen);
123+
if (unseen.length === 0) {
124+
return;
140125
}
141-
if (unseen.length > 0 || seen.size !== new Set(keys).size) {
126+
const notifiable = !areNotificationsDisabled(
127+
vscode.workspace.getConfiguration(),
128+
);
129+
if (options.notify && notifiable) {
130+
this.showPopup(unseen);
131+
}
132+
// Mark seen only what the user could see; suppressed banners notify later.
133+
if (!options.notify || notifiable) {
142134
await this.secretsManager.setSeenBanners(
143135
session.deployment.safeHostname,
144-
keys,
136+
[...seen, ...unseen.map((banner) => banner.key)],
145137
);
146138
}
147-
return banners;
148139
}
149140

150-
private setBanners(banners: readonly Announcement[]): void {
151-
this.banners = banners;
141+
/** The setter syncs the status bar so the two cannot drift apart. */
142+
private get banners(): readonly Announcement[] {
143+
return this.#banners;
144+
}
145+
146+
private set banners(banners: readonly Announcement[]) {
147+
this.#banners = banners;
152148
if (banners.length === 0) {
153149
this.statusBarItem.hide();
154150
return;
@@ -158,17 +154,34 @@ export class AnnouncementManager implements vscode.Disposable {
158154
this.statusBarItem.show();
159155
}
160156

161-
private async showPopup(banners: readonly Announcement[]): Promise<void> {
162-
try {
163-
const action = await vscode.window.showInformationMessage(
164-
popupMessage(banners),
165-
VIEW_ACTION,
166-
);
167-
if (action === VIEW_ACTION) {
168-
void this.showAnnouncements();
169-
}
170-
} catch (error) {
171-
this.logger.warn("Failed to show Coder announcement popup", error);
157+
/** Non-modal messages may never settle, so chain instead of awaiting. */
158+
private showPopup(banners: readonly Announcement[]): void {
159+
Promise.resolve(
160+
vscode.window.showInformationMessage(popupMessage(banners), VIEW_ACTION),
161+
)
162+
.then((action) =>
163+
// Banners are seconds old; skip the refetch.
164+
action === VIEW_ACTION ? this.pickAnnouncement() : undefined,
165+
)
166+
.catch((error) => {
167+
this.logger.warn("Failed to show Coder announcement popup", error);
168+
});
169+
}
170+
171+
private async pickAnnouncement(): Promise<void> {
172+
const selected = await vscode.window.showQuickPick(
173+
this.banners.map((banner, index) => ({
174+
label: `$(megaphone) Announcement ${index + 1}`,
175+
detail: banner.message,
176+
banner,
177+
})),
178+
{
179+
title: "Coder Announcements",
180+
placeHolder: "Select an announcement to view the full message",
181+
},
182+
);
183+
if (selected) {
184+
void vscode.window.showInformationMessage(selected.banner.message);
172185
}
173186
}
174187

src/remote/remote.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
type AuthorityParts,
4444
parseRemoteAuthority,
4545
} from "../util/authority";
46+
import { createStatusBarItem } from "../util/statusBar";
4647
import { vscodeProposed } from "../vscodeProposed";
4748
import { WorkspaceMonitor } from "../workspace/workspaceMonitor";
4849

@@ -1080,10 +1081,7 @@ export class Remote {
10801081
agent: WorkspaceAgent,
10811082
client: CoderApi,
10821083
): Promise<vscode.Disposable[]> {
1083-
const statusBarItem = vscode.window.createStatusBarItem(
1084-
"agentMetadata",
1085-
vscode.StatusBarAlignment.Left,
1086-
);
1084+
const statusBarItem = createStatusBarItem("agentMetadata");
10871085

10881086
const agentWatcher = await createAgentMetadataWatcher(agent.id, client);
10891087

src/remote/sshProcess.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import * as vscode from "vscode";
66
import { SshTelemetry, type ProcessLossCause } from "../instrumentation/ssh";
77
import { findPort } from "../util";
88
import { cleanupFiles } from "../util/fileCleanup";
9+
import { createStatusBarItem } from "../util/statusBar";
910

1011
import { NetworkStatusReporter } from "./networkStatus";
1112
import {
@@ -140,10 +141,7 @@ export class SshProcessMonitor implements vscode.Disposable {
140141
networkPollInterval: options.networkPollInterval ?? 3000,
141142
};
142143
this.telemetry = new SshTelemetry(options.telemetry);
143-
this.statusBarItem = vscode.window.createStatusBarItem(
144-
vscode.StatusBarAlignment.Left,
145-
1000,
146-
);
144+
this.statusBarItem = createStatusBarItem("networkStatus");
147145
this.reporter = new NetworkStatusReporter(this.statusBarItem);
148146
}
149147

0 commit comments

Comments
 (0)